Sheet

Rust UI component that displays a sheet.

navigation
  • Rust UI Icons - CopyCopy Demo

Sheet Title

This is the content inside the sheet.

use leptos::html::Div;
use leptos::prelude::*;
use leptos_use::on_click_outside;

use crate::components::hooks::use_lock_body_scroll::use_lock_body_scroll;
use crate::components::ui::sheet::{SheetCancel, SheetContent, SheetDescription, SheetTitle, SheetTrigger, SheetVariant};

#[component]
pub fn DemoSheet() -> impl IntoView {
    let is_open = RwSignal::new(false);
    let scroll_locked = use_lock_body_scroll(false);
    let _sheet_ref = NodeRef::<Div>::new();

    Effect::new(move |_| {
        scroll_locked.set(is_open.get());
    });

    let toggle_sheet = move |_| {
        is_open.update(|v| {
            *v = !*v;
        });
    };

    Effect::new(move |_| {
        if is_open.get() {
            let handle_click_outside = move || {
                is_open.set(false);
            };
            let _ = on_click_outside(_sheet_ref.get(), move |_| handle_click_outside());
        }
    });

    view! {
        <>
            <SheetTrigger on:click=toggle_sheet>"Open Sheet"</SheetTrigger>

            <div node_ref=_sheet_ref>
                <SheetContent is_open=is_open class="w-[400px]">
                    <SheetTitle>{"Sheet Title"}</SheetTitle>
                    <SheetDescription>{"This is the content inside the sheet."}</SheetDescription>

                    <SheetCancel on:click=toggle_sheet variant=SheetVariant::Destructive>
                        {"Cancel"}
                    </SheetCancel>
                </SheetContent>
            </div>
        </>
    }
}

Installation

You can run either of the following commands:

# cargo install ui-cli --force
ui add demo_sheet
ui add sheet

Update the imports to match your project setup.

Copy and paste the following code into your project:

components/ui/sheet.rs

use icons::X;
use leptos::prelude::*;
use leptos_ui::clx;
use tw_merge::*;

use super::button::ButtonSize;
use crate::components::ui::button::{Button, ButtonVariant};

// TODO. Improve the use of Button in SheetTrigger
// TODO. USe Heading variants from Headings

mod components {
    use super::*;
    clx! {SheetTitle, h2, "font-bold text-2xl"}
    clx! {SheetDescription, p, ""}
}

pub use components::*;

/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
/*                     ✨ FUNCTIONS ✨                        */
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/

pub type SheetVariant = ButtonVariant;
pub type SheetSize = ButtonSize;

#[component]
pub fn SheetTrigger(
    #[prop(into, optional)] class: Signal<String>,
    #[prop(into, optional)] variant: Signal<SheetVariant>,
    #[prop(into, optional)] size: Signal<SheetSize>,
    children: Children,
) -> impl IntoView {
    let class = Memo::new(move |_| tw_merge!("", class()));

    view! {
        <Button class=class variant=variant size=size>
            {children()}
        </Button>
    }
}

#[component]
pub fn SheetCancel(
    #[prop(into, optional)] class: Signal<String>,
    #[prop(into, optional)] variant: Signal<ButtonVariant>,
    children: Children,
) -> impl IntoView {
    let class = Memo::new(move |_| tw_merge!("", class()));

    view! {
        <Button class=class variant=variant>
            {children()}
        </Button>
    }
}

//
// Update the SheetContent component
#[component]
pub fn SheetContent(
    #[prop(into, optional)] class: Signal<String>,
    #[prop(into)] is_open: RwSignal<bool>,
    #[prop(into, default = SheetDirection::Right.into())] direction: Signal<SheetDirection>,
    children: Children,
) -> impl IntoView {
    let outer_class = Memo::new(move |_| {
        let direction = direction.get();
        tw_merge!(
            "fixed z-200 shadow-lg transform transition-transform duration-300",
            direction.initial_position(),
            direction.to_class(is_open.get()),
            class()
        )
    });

    let inner_class = Memo::new(move |_| {
        let base_class =
            "p-4 h-screen bg-card transition-opacity duration-300    overflow-y-auto shortfix__sidenav_todo_properly";
        let opacity_class = if is_open.get() { "opacity-100" } else { "opacity-0 pointer-events-none" };
        tw_merge!(base_class, opacity_class)
    });

    let close = move |_| {
        is_open.set(false);
    };

    view! {
        <div class=outer_class>
            <div class=inner_class>
                <button class="absolute top-0 right-0 m-2" on:click=close>
                    <X class="size-6" />
                </button>
                {children()}
            </div>
        </div>
    }
}

/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
/*                     ✨ FUNCTIONS ✨                        */
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/

#[derive(Clone, Copy)]
pub enum SheetDirection {
    Right,
    Left,
    Top,
    Bottom,
}

impl SheetDirection {
    fn to_class(self, is_open: bool) -> &'static str {
        match self {
            SheetDirection::Right => match is_open {
                true => "translate-x-0",
                _ => "translate-x-full",
            },
            SheetDirection::Left => match is_open {
                true => "translate-x-0",
                _ => "-translate-x-full",
            },
            SheetDirection::Top => match is_open {
                true => "translate-y-0",
                _ => "-translate-y-full",
            },
            SheetDirection::Bottom => match is_open {
                true => "translate-y-0",
                _ => "translate-y-full",
            },
        }
    }

    fn initial_position(self) -> &'static str {
        match self {
            SheetDirection::Right => "top-0 right-0 h-full w-64",
            SheetDirection::Left => "top-0 left-0 h-full w-64",
            SheetDirection::Top => "top-0 left-0 w-full h-64",
            SheetDirection::Bottom => "bottom-0 left-0 w-full h-64",
        }
    }
}

Update the imports to match your project setup.

Usage

// Coming soon 🦀

Examples

Directions

  • Rust UI Icons - CopyCopy Demo

Sheet Title

This is the content inside the sheet.

Sheet Title

This is the content inside the sheet.

Sheet Title

This is the content inside the sheet.

Sheet Title

This is the content inside the sheet.

use leptos::html::Div;
use leptos::prelude::*;
use leptos_use::on_click_outside;

use crate::components::hooks::use_lock_body_scroll::use_lock_body_scroll;
use crate::components::ui::sheet::{
    SheetCancel, SheetContent, SheetDescription, SheetDirection, SheetTitle, SheetTrigger, SheetVariant,
};

// TODO later. Refactor this to use a single component.

#[component]
pub fn DemoSheetDirections() -> impl IntoView {
    view! {
        <div class="flex flex-col gap-4 items-center">
            <DemoSheetTop />
            <div class="flex gap-4">
                <DemoSheetLeft />
                <DemoSheetRight />
            </div>
            <DemoSheetBottom />
        </div>
    }
}

/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
/*                     ✨ FUNCTIONS ✨                        */
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/

#[component]
pub fn DemoSheetTop() -> impl IntoView {
    let is_open = RwSignal::new(false);
    let scroll_locked = use_lock_body_scroll(false);
    let _sheet_ref = NodeRef::<Div>::new();

    Effect::new(move |_| {
        scroll_locked.set(is_open.get());
    });

    let toggle_sheet = move |_| {
        is_open.update(|v| {
            *v = !*v;
        });
    };

    Effect::new(move |_| {
        if is_open.get() {
            let handle_click_outside = move || {
                is_open.set(false);
            };
            let _ = on_click_outside(_sheet_ref.get(), move |_| handle_click_outside());
        }
    });

    view! {
        <>
            <SheetTrigger on:click=toggle_sheet>"Top"</SheetTrigger>

            <div node_ref=_sheet_ref>
                <SheetContent direction=SheetDirection::Top is_open=is_open class="w-full h-[200px]">
                    <SheetTitle>{"Sheet Title"}</SheetTitle>
                    <SheetDescription>{"This is the content inside the sheet."}</SheetDescription>

                    <SheetCancel on:click=toggle_sheet variant=SheetVariant::Destructive>
                        {"Cancel"}
                    </SheetCancel>
                </SheetContent>
            </div>
        </>
    }
}

#[component]
pub fn DemoSheetLeft() -> impl IntoView {
    let is_open = RwSignal::new(false);
    let scroll_locked = use_lock_body_scroll(false);
    let _sheet_ref = NodeRef::<Div>::new();

    Effect::new(move |_| {
        scroll_locked.set(is_open.get());
    });

    let toggle_sheet = move |_| {
        is_open.update(|v| {
            *v = !*v;
        });
    };

    Effect::new(move |_| {
        if is_open.get() {
            let handle_click_outside = move || {
                is_open.set(false);
            };
            let _ = on_click_outside(_sheet_ref.get(), move |_| handle_click_outside());
        }
    });

    view! {
        <>
            <SheetTrigger on:click=toggle_sheet>"Left"</SheetTrigger>

            <div node_ref=_sheet_ref>
                <SheetContent direction=SheetDirection::Left is_open=is_open class="w-[400px]">
                    <SheetTitle>{"Sheet Title"}</SheetTitle>
                    <SheetDescription>{"This is the content inside the sheet."}</SheetDescription>

                    <SheetCancel on:click=toggle_sheet variant=SheetVariant::Destructive>
                        {"Cancel"}
                    </SheetCancel>
                </SheetContent>
            </div>
        </>
    }
}

#[component]
pub fn DemoSheetRight() -> impl IntoView {
    let is_open = RwSignal::new(false);
    let scroll_locked = use_lock_body_scroll(false);
    let _sheet_ref = NodeRef::<Div>::new();

    Effect::new(move |_| {
        scroll_locked.set(is_open.get());
    });

    let toggle_sheet = move |_| {
        is_open.update(|v| {
            *v = !*v;
        });
    };

    Effect::new(move |_| {
        if is_open.get() {
            let handle_click_outside = move || {
                is_open.set(false);
            };
            let _ = on_click_outside(_sheet_ref.get(), move |_| handle_click_outside());
        }
    });

    view! {
        <>
            <SheetTrigger on:click=toggle_sheet>"Right"</SheetTrigger>

            <div node_ref=_sheet_ref>
                <SheetContent is_open=is_open class="w-[400px]">
                    <SheetTitle>{"Sheet Title"}</SheetTitle>
                    <SheetDescription>{"This is the content inside the sheet."}</SheetDescription>

                    <SheetCancel on:click=toggle_sheet variant=SheetVariant::Destructive>
                        {"Cancel"}
                    </SheetCancel>
                </SheetContent>
            </div>
        </>
    }
}

#[component]
pub fn DemoSheetBottom() -> impl IntoView {
    let is_open = RwSignal::new(false);
    let scroll_locked = use_lock_body_scroll(false);
    let _sheet_ref = NodeRef::<Div>::new();

    Effect::new(move |_| {
        scroll_locked.set(is_open.get());
    });

    let toggle_sheet = move |_| {
        is_open.update(|v| {
            *v = !*v;
        });
    };

    Effect::new(move |_| {
        if is_open.get() {
            let handle_click_outside = move || {
                is_open.set(false);
            };
            let _ = on_click_outside(_sheet_ref.get(), move |_| handle_click_outside());
        }
    });

    view! {
        <>
            <SheetTrigger on:click=toggle_sheet>"Bottom"</SheetTrigger>

            <div node_ref=_sheet_ref>
                <SheetContent direction=SheetDirection::Bottom is_open=is_open class="w-full">
                    <SheetTitle>{"Sheet Title"}</SheetTitle>
                    <SheetDescription>{"This is the content inside the sheet."}</SheetDescription>

                    <SheetCancel on:click=toggle_sheet variant=SheetVariant::Destructive>
                        {"Cancel"}
                    </SheetCancel>
                </SheetContent>
            </div>
        </>
    }
}

Experimental

  • Rust UI Icons - CopyCopy Demo
use leptos::prelude::*;
use leptos_ui::clx;

#[component]
pub fn DemoSheetExperimental() -> impl IntoView {
    clx! {SheetTrigger, button, "bg-none border-none cursor-pointer text-2xl flex items-center"}

    // TODO. `anchor` attribute is still an experimental feature:
    // TODO. See: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/anchor
    const TARGET_ID: &str = "sheet__target";
    const ANCHOR_ID: &str = "menu__anchor";

    view! {
        <style>
            {"nav.my__sheet[popover] {
            transform: translateX(-100%);
            }
            
            nav.my__sheet:popover-open {
            transform: translateX(0);
            }
            "}
        </style>

        <div>
            <button
                class="flex items-center text-2xl bg-none border-none cursor-pointer"
                id=ANCHOR_ID
                popovertarget=TARGET_ID
                popovertargetaction="toggle"
                aria-label="Open settings my_sheet"
            >
                "☰"
            </button>

            <nav
                id="sheet__target"
                // id=TARGET_ID
                anchor="menu__anchor"
                // anchor=ANCHOR_ID
                class="flex fixed inset-y-0 left-0 z-50 flex-col justify-between p-4 border-r transition-transform duration-300 ease-in-out my__sheet w-[320px] h-[100dvh] bg-neutral-200 border-r-gray-200"
                popover
            >

                <div class="relative">
                    <button
                        class="absolute top-2 right-2 p-2 rounded-lg border cursor-pointer text-neutral-500 border-neutral-300"
                        popovertarget="sheet__target"
                        // popovertarget=TARGET_ID.
                        popovertargetaction="hide"
                    >
                        "X"
                    </button>

                    <h2>Workspace Settings</h2>
                    <ul>
                        <li>
                            <a href="#">Team</a>
                        </li>
                        <li>
                            <a href="#">Billing</a>
                        </li>
                        <li>
                            <a href="#">Integrations</a>
                        </li>
                        <li>
                            <a href="#">Keyboard Shortcuts</a>
                        </li>
                    </ul>
                </div>
                <footer class="text-right">
                    <button
                        class="py-2 px-4 text-white rounded-md bg-sky-500"
                        popovertarget="sheet__target"
                        // popovertarget=TARGET_ID.
                        popovertargetaction="hide"
                    >
                        Close
                    </button>
                </footer>
            </nav>

        </div>
    }
}