diff --git a/README.md b/README.md index 672d0203..beab192b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/component.json b/component.json index 4e8efef2..c84d50e3 100644 --- a/component.json +++ b/component.json @@ -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" ] } diff --git a/playwright/sheet.spec.ts b/playwright/sheet.spec.ts new file mode 100644 index 00000000..f0e51595 --- /dev/null +++ b/playwright/sheet.spec.ts @@ -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); +}); diff --git a/preview/src/components/mod.rs b/preview/src/components/mod.rs index 75791d5c..8fb5d326 100644 --- a/preview/src/components/mod.rs +++ b/preview/src/components/mod.rs @@ -82,6 +82,7 @@ examples!( select, separator, skeleton, + sheet, slider, switch, tabs, diff --git a/preview/src/components/sheet/component.json b/preview/src/components/sheet/component.json new file mode 100644 index 00000000..760335f1 --- /dev/null +++ b/preview/src/components/sheet/component.json @@ -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" + ] +} diff --git a/preview/src/components/sheet/component.rs b/preview/src/components/sheet/component.rs new file mode 100644 index 00000000..bca7e578 --- /dev/null +++ b/preview/src/components/sheet/component.rs @@ -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>, + #[props(default)] side: SheetSide, + #[props(default)] class: Option, + #[props(extends = GlobalAttributes)] attributes: Vec, + 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, + children: Element, +) -> Element { + rsx! { + div { class: "sheet-header", "data-slot": "sheet-header", ..attributes, {children} } + } +} + +#[component] +pub fn SheetFooter( + #[props(extends = GlobalAttributes)] attributes: Vec, + 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, + r#as: Option, Element>>, + children: Option, +) -> Element { + let ctx: DialogCtx = use_context(); + + let mut merged_attributes: Vec = 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} } + } + } +} diff --git a/preview/src/components/sheet/docs.md b/preview/src/components/sheet/docs.md new file mode 100644 index 00000000..38fef7cc --- /dev/null +++ b/preview/src/components/sheet/docs.md @@ -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