Sheet
A panel that slides in from the edge of the screen. Built on the Dialog primitive.
Default
Opens from the right side by default.
Edit Profile
Make changes to your profile here. Click save when you're done.
Side Variants
The sheet can slide in from any edge.
Top Sheet
This sheet slides down from the top.
Right Sheet
This sheet slides in from the right.
Bottom Sheet
This sheet slides up from the bottom.
Left Sheet
This sheet slides in from the left.
Custom Size
Override the default width with a custom class.
Wide Sheet
This sheet is wider than the default sm:max-w-sm.
Use the class prop on SheetContent to override the max-width.
FreeSheet
Simple side-sheet disclosure layout.
Install with the Proa CLI
$ proa ui add sheetInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →recipe.rs
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Sheet recipe — typed content appearance and named compound-part slots.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Which edge the sheet slides in from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "side")]
pub enum SheetSide {
Top,
#[default]
Right,
Bottom,
Left,
}
impl SheetSide {
pub const fn as_str(self) -> &'static str {
Sheet source
Browse and copy the reviewed source included with this Free component.
md.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
use super::{
Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,
SheetTrigger,
};
use crate::components::md_support::block_boundary;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
use proa_macros::md_sync;
/// Sheet renders its content as a plain block — the trigger is interaction chrome and is dropped.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, Trigger, Content: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for Sheet<Trigger, Content>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
self.content.render_md(cx)?;
block_boundary(cx)?;
Ok(())
}
}
/// SheetTrigger is interaction chrome — renders nothing.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C> MdRenderSync<Loader, B> for SheetTrigger<C> {
fn render_md(self, _cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
Ok(())
}
}
/// SheetContent renders its children as a plain block (side and close button are dropped).
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for SheetContent<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
self.children.render_md(cx)?;
block_boundary(cx)?;
Ok(())
}
}
/// SheetHeader is a passthrough — its children (title, description) handle formatting.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for SheetHeader<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
self.children.render_md(cx)?;
Ok(())
}
}
/// SheetTitle renders as a bold line: `**title**`.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for SheetTitle<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
let prev = cx.md_set_inline(true);
md_sync! { "**" { self.children } "**" }.render_md(cx)?;
cx.md_set_inline(prev);
block_boundary(cx)?;
Ok(())
}
}
/// SheetDescription renders as a plain paragraph.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for SheetDescription<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
self.children.render_md(cx)?;
block_boundary(cx)?;
Ok(())
}
}
/// SheetFooter is a passthrough for its children (action buttons render as labels).
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for SheetFooter<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
self.children.render_md(cx)?;
block_boundary(cx)?;
Ok(())
}
}
/// SheetClose is interaction chrome — renders nothing.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C> MdRenderSync<Loader, B> for SheetClose<C> {
fn render_md(self, _cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sheet_title_renders_bold() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
SheetTitle {
class: None,
children: Some("Edit Profile"),
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "**Edit Profile**\n\n");
}
#[test]
fn sheet_close_renders_nothing() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
SheetClose {
class: None,
children: Some("Close"),
}
.render_md(&mut cx)
.unwrap();
assert!(buf.is_empty());
}
#[test]
fn sheet_renders_content_and_drops_trigger() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Sheet {
trigger: Some("Open Sheet"),
content: Some(SheetContent {
children: Some(SheetHeader {
class: None,
children: Some((
SheetTitle {
class: None,
children: Some("Edit Profile"),
},
SheetDescription {
class: None,
children: Some("Make changes to your profile."),
},
)),
}),
..Default::default()
}),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("**Edit Profile**"), "output: {output}");
assert!(
output.contains("Make changes to your profile\\."),
"output: {output}"
);
assert!(!output.contains("Open Sheet"), "output: {output}");
}
/// A block-emitting Sheet after mid-line prose must not fuse with it.
#[test]
fn sheet_composes_after_inline_text() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
cx.write_md_prepared_static(b"intro prose").unwrap();
Sheet {
trigger: Some("Open Sheet"),
content: Some(SheetContent {
children: Some(SheetHeader {
class: None,
children: Some((
SheetTitle {
class: None,
children: Some("Edit Profile"),
},
SheetDescription {
class: None,
children: Some("Make changes to your profile."),
},
)),
}),
..Default::default()
}),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.starts_with("intro prose\n\n**Edit Profile**\n\n"),
"block content must start after a blank line, got:\n{output}"
);
}
}
mod.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Sheet component - a side-sliding dialog panel
//!
//! Extends the Dialog behavior with slide-from-edge positioning.
//! Reuses the Dialog JS handler via `data-dialog-*` attributes.
use crate::Anatomy;
mod md;
pub mod recipe;
mod sheet;
/// Static anatomy contract for Sheet's public compound parts.
pub const SHEET_ANATOMY: Anatomy = Anatomy::new(
"sheet",
&[
"root",
"trigger",
"portal",
"overlay",
"content",
"close",
"close-label",
"header",
"title",
"description",
"footer",
],
);
pub use recipe::{
sheet_content_classes, sheet_recipe, SheetRecipe, SheetRecipeProps, SheetSide, SheetStyle,
SHEET_RECIPE,
};
pub use sheet::{
Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,
SheetTrigger,
};
#[cfg(test)]
mod anatomy_tests {
use super::*;
#[test]
fn anatomy_declares_every_public_part() {
assert_eq!(SHEET_ANATOMY.scope(), "sheet");
assert_eq!(
SHEET_ANATOMY.parts(),
&[
"root",
"trigger",
"portal",
"overlay",
"content",
"close",
"close-label",
"header",
"title",
"description",
"footer",
]
);
}
}
recipe.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Sheet recipe — typed content appearance and named compound-part slots.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Which edge the sheet slides in from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "side")]
pub enum SheetSide {
Top,
#[default]
Right,
Bottom,
Left,
}
impl SheetSide {
pub const fn as_str(self) -> &'static str {
match self {
Self::Top => "top",
Self::Right => "right",
Self::Bottom => "bottom",
Self::Left => "left",
}
}
/// Compatibility alias for the historical `data-side` helper.
pub const fn data_value(self) -> &'static str {
self.as_str()
}
pub const fn classes(self) -> &'static str {
match self {
Self::Top => SHEET_SIDE_TOP,
Self::Right => SHEET_SIDE_RIGHT,
Self::Bottom => SHEET_SIDE_BOTTOM,
Self::Left => SHEET_SIDE_LEFT,
}
}
}
/// Optional content appearance accepted by [`SheetRecipe::resolve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SheetRecipeProps {
pub side: Option<SheetSide>,
}
/// Static classes for every Sheet compound part.
#[derive(Debug, Clone, Copy)]
pub struct SheetRecipe {
pub root: &'static str,
pub trigger: &'static str,
pub overlay: &'static str,
pub content: &'static str,
pub header: &'static str,
pub footer: &'static str,
pub title: &'static str,
pub description: &'static str,
pub close: &'static str,
pub close_label: &'static str,
side_default: SheetSide,
}
impl SheetRecipe {
/// Resolve the content side through the single canonical recipe path.
pub const fn resolve(&'static self, props: SheetRecipeProps) -> SheetStyle {
let side = match props.side {
Some(side) => side,
None => self.side_default,
};
SheetStyle { recipe: self, side }
}
}
/// Fully resolved, allocation-free Sheet content style.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct SheetStyle {
recipe: &'static SheetRecipe,
side: SheetSide,
}
impl SheetStyle {
pub const fn side(self) -> SheetSide {
self.side
}
}
impl AttrValue for SheetStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.recipe.content.as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.side.classes().as_bytes())
}
}
const SHEET_ROOT: &str = "relative";
const SHEET_TRIGGER: &str = tw_join!(
"inline-flex",
"items-center",
"justify-center",
"gap-2",
"whitespace-nowrap",
"text-sm",
"font-medium",
"transition-all",
"cursor-pointer",
"rounded-md",
"h-9",
"px-4",
"py-2",
"border",
"border-input",
"bg-background",
"text-foreground",
"shadow-xs",
"hover:bg-accent",
"hover:text-accent-foreground"
);
const SHEET_OVERLAY: &str = tw_join!(
"fixed",
"inset-0",
"z-50",
"bg-black/50",
"data-[state=open]:animate-in",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=open]:fade-in-0"
);
const SHEET_CONTENT: &str = tw_join!(
"fixed",
"z-50",
"flex",
"flex-col",
"gap-4",
"bg-background",
"p-6",
"shadow-lg"
);
const SHEET_SIDE_RIGHT: &str = tw_join!(
"inset-y-0",
"right-0",
"h-full",
"w-3/4",
"border-l",
"border-border",
"sm:max-w-sm"
);
const SHEET_SIDE_LEFT: &str = tw_join!(
"inset-y-0",
"left-0",
"h-full",
"w-3/4",
"border-r",
"border-border",
"sm:max-w-sm"
);
const SHEET_SIDE_TOP: &str = tw_join!("inset-x-0", "top-0", "border-b", "border-border");
const SHEET_SIDE_BOTTOM: &str = tw_join!("inset-x-0", "bottom-0", "border-t", "border-border");
const SHEET_HEADER: &str = tw_join!("flex", "flex-col", "gap-1.5");
const SHEET_FOOTER: &str = tw_join!("mt-auto", "flex", "flex-col", "gap-2");
const SHEET_TITLE: &str = tw_join!("text-lg", "font-semibold", "text-foreground");
const SHEET_DESCRIPTION: &str = tw_join!("text-sm", "text-muted-foreground");
const SHEET_CLOSE: &str = tw_join!(
"absolute",
"right-4",
"top-4",
"rounded-sm",
"opacity-70",
"ring-offset-background",
"transition-opacity",
"hover:opacity-100",
"focus:outline-hidden",
"focus:ring-2",
"focus:ring-ring",
"focus:ring-offset-2",
"disabled:pointer-events-none"
);
/// Global, zero-initialization Sheet recipe.
pub const SHEET_RECIPE: SheetRecipe = SheetRecipe {
root: SHEET_ROOT,
trigger: SHEET_TRIGGER,
overlay: SHEET_OVERLAY,
content: SHEET_CONTENT,
header: SHEET_HEADER,
footer: SHEET_FOOTER,
title: SHEET_TITLE,
description: SHEET_DESCRIPTION,
close: SHEET_CLOSE,
close_label: "sr-only",
side_default: SheetSide::Right,
};
pub const fn sheet_recipe() -> &'static SheetRecipe {
&SHEET_RECIPE
}
/// Compatibility resolver for callers that need the content classes.
pub const fn sheet_content_classes(side: SheetSide) -> SheetStyle {
SHEET_RECIPE.resolve(SheetRecipeProps { side: Some(side) })
}
pub const fn sheet_overlay_classes() -> &'static str {
SHEET_RECIPE.overlay
}
pub const fn sheet_content_base() -> &'static str {
SHEET_RECIPE.content
}
pub const fn sheet_header_classes() -> &'static str {
SHEET_RECIPE.header
}
pub const fn sheet_footer_classes() -> &'static str {
SHEET_RECIPE.footer
}
pub const fn sheet_title_classes() -> &'static str {
SHEET_RECIPE.title
}
pub const fn sheet_description_classes() -> &'static str {
SHEET_RECIPE.description
}
pub const fn sheet_close_classes() -> &'static str {
SHEET_RECIPE.close
}
impl crate::variant_spec::ComponentSpec for SheetRecipe {
const NAME: &'static str = "Sheet";
fn base_classes(&self) -> &'static str {
self.content
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
vec![crate::variant_spec::variant_metadata::<SheetSide>()]
}
fn anatomy(&self) -> Option<crate::Anatomy> {
Some(super::SHEET_ANATOMY)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recipe_exposes_every_named_slot() {
let recipe = sheet_recipe();
assert_eq!(recipe.root, "relative");
assert!(recipe.trigger.contains("inline-flex"));
assert!(recipe.overlay.contains("bg-black/50"));
assert!(recipe.content.contains("fixed"));
assert!(recipe.header.contains("gap-1.5"));
assert!(recipe.footer.contains("mt-auto"));
assert!(recipe.title.contains("font-semibold"));
assert!(recipe.description.contains("text-muted-foreground"));
assert!(recipe.close.contains("right-4"));
assert_eq!(recipe.close_label, "sr-only");
assert_eq!(
crate::variant_spec::ComponentSpec::anatomy(recipe),
Some(super::super::SHEET_ANATOMY)
);
}
#[test]
fn recipe_resolves_default_and_side_overrides() {
let default = SHEET_RECIPE.resolve(SheetRecipeProps::default());
assert_eq!(default.side(), SheetSide::Right);
for (side, expected) in [
(SheetSide::Top, "top-0"),
(SheetSide::Right, "right-0"),
(SheetSide::Bottom, "bottom-0"),
(SheetSide::Left, "left-0"),
] {
let style = SHEET_RECIPE.resolve(SheetRecipeProps { side: Some(side) });
assert_eq!(style.side(), side);
assert!(style.side().classes().contains(expected));
}
}
#[test]
fn compatibility_helpers_delegate_to_recipe_slots() {
assert_eq!(sheet_overlay_classes(), SHEET_RECIPE.overlay);
assert_eq!(sheet_content_base(), SHEET_RECIPE.content);
assert_eq!(sheet_header_classes(), SHEET_RECIPE.header);
assert_eq!(sheet_footer_classes(), SHEET_RECIPE.footer);
assert_eq!(sheet_title_classes(), SHEET_RECIPE.title);
assert_eq!(sheet_description_classes(), SHEET_RECIPE.description);
assert_eq!(sheet_close_classes(), SHEET_RECIPE.close);
assert_eq!(
sheet_content_classes(SheetSide::Left).side(),
SheetSide::Left
);
}
#[test]
fn recipe_is_fully_const() {
const STYLE: SheetStyle = SHEET_RECIPE.resolve(SheetRecipeProps {
side: Some(SheetSide::Bottom),
});
assert_eq!(STYLE.side(), SheetSide::Bottom);
}
}
sheet.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND (ISC AND MIT)
//! Sheet component implementations
//!
//! A Sheet is a side-sliding panel (like a Dialog that slides from an edge).
//! It reuses the Dialog JS handler by emitting `data-dialog-*` attributes,
//! so open/close, overlay clicks, focus trap, and body scroll lock all work
//! automatically without additional JavaScript.
use super::recipe::{SheetRecipeProps, SheetSide, SHEET_RECIPE};
use super::SHEET_ANATOMY;
use proa_core::{StaticRaw, WebContext, WebRenderSync, WriteBuf, WriteError};
use proa_macros::html_sync;
const CLOSE_ICON: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>"#;
/// Sheet root component
///
/// Renders a container with `data-dialog-root` so the existing Dialog JS handler
/// manages all interactive behavior (open/close, focus trap, scroll lock).
pub struct Sheet<Trigger = (), Content = ()> {
pub default_open: bool,
pub id: Option<&'static str>,
pub trigger: Option<Trigger>,
pub content: Option<Content>,
}
impl<Trigger, Content> Default for Sheet<Trigger, Content> {
fn default() -> Self {
Self {
default_open: false,
id: None,
trigger: None,
content: None,
}
}
}
impl<
B: WriteBuf,
Trigger: WebRenderSync<L, B>,
Content: WebRenderSync<L, B>,
L: ::proa_core::DataLoader,
> WebRenderSync<L, B> for Sheet<Trigger, Content>
{
fn render(self, cx: &mut WebContext<L, B>) -> Result<(), WriteError> {
let state = crate::OverlayState::new(self.default_open, true);
let initial_display = if state.hidden() { "none" } else { "block" };
html_sync! {
<div
id={self.id}
data-dialog-root
data-sheet
data-scope={SHEET_ANATOMY.scope()}
data-part="root"
data-slot="sheet"
data-state={state.data_state()}
data-dialog-id={self.id}
class={SHEET_RECIPE.root}
style={proa_core::style_ok(proa_macros::text!("--proa-overlay-display: {};", initial_display))}
>
{self.trigger}
{self.content}
</div>
}
.render(cx)
}
}
/// SheetTrigger - the element that opens the sheet
pub struct SheetTrigger<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for SheetTrigger<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for SheetTrigger<C>
{
fn render(self, cx: &mut WebContext<L, B>) -> Result<(), WriteError> {
html_sync! {
<button
type="button"
data-dialog-trigger
data-sheet-trigger
data-scope={SHEET_ANATOMY.scope()}
data-part="trigger"
data-slot="sheet-trigger"
aria-haspopup="dialog"
class={proa_macros::text!("{} {}", SHEET_RECIPE.trigger, self.class)}
>
{self.children}
</button>
}
.render(cx)
}
}
/// SheetContent - the sliding panel container
pub struct SheetContent<C = ()> {
pub side: SheetSide,
pub class: Option<&'static str>,
pub children: Option<C>,
pub show_close: bool,
}
impl<C> Default for SheetContent<C> {
fn default() -> Self {
Self {
side: SheetSide::Right,
class: None,
children: None,
show_close: true,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for SheetContent<C>
{
fn render(self, cx: &mut WebContext<L, B>) -> Result<(), WriteError> {
let style = SHEET_RECIPE.resolve(SheetRecipeProps {
side: Some(self.side),
});
let side = style.side().data_value();
html_sync! {
<div
data-dialog-portal
data-scope={SHEET_ANATOMY.scope()}
data-part="portal"
data-slot="sheet-portal"
style="display: var(--proa-overlay-display, none);"
>
<div
data-dialog-overlay
data-sheet-overlay
data-scope={SHEET_ANATOMY.scope()}
data-part="overlay"
data-slot="sheet-overlay"
class={SHEET_RECIPE.overlay}
aria-hidden="true"
></div>
<div
data-sheet-content
data-scope={SHEET_ANATOMY.scope()}
data-part="content"
data-slot="sheet-content"
data-side={side}
class={proa_macros::text!("{} {}", style, self.class)}
role="dialog"
aria-modal="true"
tabindex="-1"
>
{self.children}
{self.show_close.then(|| html_sync! {
<button type="button" data-dialog-close data-sheet-close data-scope={SHEET_ANATOMY.scope()} data-part="close" data-slot="sheet-close" class={SHEET_RECIPE.close}>
<span data-scope={SHEET_ANATOMY.scope()} data-part="close-label" data-slot="sheet-close-label" class={SHEET_RECIPE.close_label}>
"Close"
</span>
{StaticRaw(CLOSE_ICON)}
</button>
})}
</div>
</div>
}
.render(cx)
}
}
/// SheetHeader - container for title and description
pub struct SheetHeader<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for SheetHeader<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for SheetHeader<C>
{
fn render(self, cx: &mut WebContext<L, B>) -> Result<(), WriteError> {
html_sync! {
<div
data-sheet-header
data-scope={SHEET_ANATOMY.scope()}
data-part="header"
data-slot="sheet-header"
class={proa_macros::text!("{} {}", SHEET_RECIPE.header, self.class)}
>
{self.children}
</div>
}
.render(cx)
}
}
/// SheetTitle - accessible title
pub struct SheetTitle<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for SheetTitle<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for SheetTitle<C>
{
fn render(self, cx: &mut WebContext<L, B>) -> Result<(), WriteError> {
html_sync! {
<h2
data-sheet-title
data-scope={SHEET_ANATOMY.scope()}
data-part="title"
data-slot="sheet-title"
class={proa_macros::text!("{} {}", SHEET_RECIPE.title, self.class)}
>
{self.children}
</h2>
}
.render(cx)
}
}
/// SheetDescription - optional description text
pub struct SheetDescription<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for SheetDescription<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for SheetDescription<C>
{
fn render(self, cx: &mut WebContext<L, B>) -> Result<(), WriteError> {
html_sync! {
<p
data-sheet-description
data-scope={SHEET_ANATOMY.scope()}
data-part="description"
data-slot="sheet-description"
class={proa_macros::text!("{} {}", SHEET_RECIPE.description, self.class)}
>
{self.children}
</p>
}
.render(cx)
}
}
/// SheetFooter - container for action buttons
pub struct SheetFooter<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for SheetFooter<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for SheetFooter<C>
{
fn render(self, cx: &mut WebContext<L, B>) -> Result<(), WriteError> {
html_sync! {
<div
data-sheet-footer
data-scope={SHEET_ANATOMY.scope()}
data-part="footer"
data-slot="sheet-footer"
class={proa_macros::text!("{} {}", SHEET_RECIPE.footer, self.class)}
>
{self.children}
</div>
}
.render(cx)
}
}
/// SheetClose - element that closes the sheet when clicked
pub struct SheetClose<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for SheetClose<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for SheetClose<C>
{
fn render(self, cx: &mut WebContext<L, B>) -> Result<(), WriteError> {
html_sync! {
<span
data-dialog-close
data-sheet-close
data-scope={SHEET_ANATOMY.scope()}
data-part="close"
data-slot="sheet-close"
class={proa_macros::text!("{} {}", SHEET_RECIPE.close, self.class)}
>
{self.children}
</span>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::ctx::Ctx;
use proa_core::text::text;
#[test]
fn sheet_renders_dialog_root() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Sheet {
default_open: false,
id: Some("test-sheet"),
trigger: Some(text("Open")),
content: Some(text("Content")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-dialog-root"));
assert!(html.contains("data-sheet"));
assert!(html.contains("data-slot=\"sheet\""));
assert!(html.contains("data-state=\"closed\""));
assert!(html.contains("data-dialog-id=\"test-sheet\""));
assert!(html.contains("id=\"test-sheet\""));
assert!(html.contains("class=\"relative\""));
}
#[test]
fn sheet_default_open() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Sheet {
default_open: true,
id: None,
trigger: Some(text("Open")),
content: Some(text("Content")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-state=\"open\""));
}
#[test]
fn sheet_default_open_has_no_closed_or_hidden_descendants() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Sheet {
default_open: true,
id: Some("open-sheet"),
trigger: Some(SheetTrigger {
class: None,
children: Some(text("Open")),
}),
content: Some(SheetContent {
side: SheetSide::Right,
class: None,
children: Some(text("Visible content")),
show_close: false,
}),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("id=\"open-sheet\""));
assert!(html.contains("data-state=\"open\""));
assert!(html.contains("data-slot=\"sheet-portal\""));
assert!(html.contains("--proa-overlay-display: block"));
assert!(html.contains("display: var(--proa-overlay-display, none)"));
assert!(!html.contains("data-state=\"closed\""));
assert!(!html.contains("display: none"));
assert!(!html.contains("aria-expanded=\"false\""));
}
#[test]
fn sheet_trigger_renders_button() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
SheetTrigger {
class: Some("trigger-override"),
children: Some(text("Open Sheet")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<button"));
assert!(html.contains("data-dialog-trigger"));
assert!(html.contains("data-slot=\"sheet-trigger\""));
assert!(html.contains("aria-haspopup=\"dialog\""));
assert!(html.contains("trigger-override"));
assert!(html.contains("Open Sheet"));
}
#[test]
fn sheet_content_right_side() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
SheetContent {
side: SheetSide::Right,
class: None,
children: Some(text("Panel content")),
show_close: true,
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-dialog-portal"));
assert!(html.contains("data-dialog-overlay"));
assert!(html.contains("data-slot=\"sheet-overlay\""));
assert!(html.contains("data-sheet-content"));
assert!(html.contains("data-slot=\"sheet-content\""));
assert!(html.contains("data-side=\"right\""));
assert!(html.contains("role=\"dialog\""));
assert!(html.contains("aria-modal=\"true\""));
assert!(html.contains("aria-hidden=\"true\""));
assert!(html.contains("right-0"));
assert!(html.contains("sm:max-w-sm"));
assert!(html.contains("Panel content"));
assert!(html.contains("data-dialog-close"));
assert!(html.contains("data-slot=\"sheet-close\""));
assert!(!html.contains("data-dialog-content"));
}
#[test]
fn sheet_content_left_side() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
SheetContent {
side: SheetSide::Left,
class: None,
children: Some(text("Left panel")),
show_close: true,
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-side=\"left\""));
assert!(html.contains("left-0"));
}
#[test]
fn sheet_content_top_side() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
SheetContent {
side: SheetSide::Top,
class: None,
children: Some(text("Top")),
show_close: true,
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-side=\"top\""));
assert!(html.contains("top-0"));
}
#[test]
fn sheet_content_bottom_side() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
SheetContent {
side: SheetSide::Bottom,
class: None,
children: Some(text("Bottom")),
show_close: true,
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-side=\"bottom\""));
assert!(html.contains("bottom-0"));
}
#[test]
fn sheet_content_no_close_button() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
SheetContent {
side: SheetSide::Right,
class: None,
children: Some(text("No close")),
show_close: false,
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(!html.contains("data-dialog-close"));
assert!(!html.contains("Close"));
}
#[test]
fn sheet_content_custom_class() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
SheetContent {
side: SheetSide::Right,
class: Some("w-[400px]"),
children: Some(text("Wide")),
show_close: true,
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("w-[400px]"));
}
#[test]
fn sheet_header_renders() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
SheetHeader {
class: Some("header-override"),
children: Some(text("Header")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-slot=\"sheet-header\""));
assert!(html.contains("header-override"));
assert!(html.contains("Header"));
}
#[test]
fn sheet_title_renders_h2() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
SheetTitle {
class: Some("title-override"),
children: Some(text("Edit Profile")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<h2"));
assert!(html.contains("font-semibold"));
assert!(html.contains("data-slot=\"sheet-title\""));
assert!(html.contains("title-override"));
assert!(html.contains("Edit Profile"));
}
#[test]
fn sheet_description_renders_p() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
SheetDescription {
class: Some("description-override"),
children: Some(text("Make changes")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<p"));
assert!(html.contains("text-muted-foreground"));
assert!(html.contains("data-slot=\"sheet-description\""));
assert!(html.contains("description-override"));
assert!(html.contains("Make changes"));
}
#[test]
fn sheet_footer_renders() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
SheetFooter {
class: Some("footer-override"),
children: Some(text("Actions")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-slot=\"sheet-footer\""));
assert!(html.contains("mt-auto"));
assert!(html.contains("footer-override"));
assert!(html.contains("Actions"));
}
#[test]
fn sheet_close_renders_with_data_attr() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
SheetClose {
class: Some("close-override"),
children: Some(text("Cancel")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-dialog-close"));
assert!(html.contains("data-slot=\"sheet-close\""));
assert!(html.contains("close-override"));
assert!(html.contains("Cancel"));
}
#[test]
fn full_sheet_structure() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Sheet {
default_open: false,
id: None,
trigger: Some(SheetTrigger {
class: None,
children: Some(text("Open")),
}),
content: Some(SheetContent {
side: SheetSide::Right,
class: None,
children: Some(SheetHeader {
class: None,
children: Some(SheetTitle {
class: None,
children: Some(text("Edit Profile")),
}),
}),
show_close: true,
}),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-dialog-root"));
assert!(html.contains("data-dialog-trigger"));
assert!(html.contains("Open"));
assert!(html.contains("data-dialog-portal"));
assert!(html.contains("data-dialog-overlay"));
assert!(html.contains("data-sheet-content"));
assert!(html.contains("data-side=\"right\""));
assert!(html.contains("Edit Profile"));
}
#[test]
fn every_declared_anatomy_part_is_emitted_by_a_public_renderer() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Sheet::<(), ()>::default().render(&mut cx).unwrap();
SheetTrigger {
class: None,
children: Some(text("Open")),
}
.render(&mut cx)
.unwrap();
SheetContent {
side: SheetSide::Right,
class: None,
children: Some(text("Content")),
show_close: true,
}
.render(&mut cx)
.unwrap();
SheetHeader {
class: None,
children: Some(text("Header")),
}
.render(&mut cx)
.unwrap();
SheetTitle {
class: None,
children: Some(text("Title")),
}
.render(&mut cx)
.unwrap();
SheetDescription {
class: None,
children: Some(text("Description")),
}
.render(&mut cx)
.unwrap();
SheetFooter {
class: None,
children: Some(text("Footer")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
for part in SHEET_ANATOMY.parts() {
let contract = format!("data-scope=\"sheet\" data-part=\"{part}\"");
assert!(
html.contains(&contract),
"missing anatomy part {part}: {html}"
);
}
}
}