Skip to main content

orbital_core_components/layout/
layout.rs

1use leptos::prelude::*;
2use orbital_base_components::{
3    AppBarDensity, AppBarInset, BaseLayout, BaseLayoutBody, LayoutPosition,
4};
5use orbital_macros::component_doc;
6
7use super::context::LayoutSidebarOpen;
8use super::main::LayoutMainShell;
9use super::overlay::LayoutOverlayScroll;
10use super::sidebar::LayoutSidebarShell;
11use super::slots::{LayoutHeader, LayoutMain, LayoutSidebar};
12use super::styles::layout_styles;
13use crate::ScrollArea;
14
15/// Application shell with optional overlay header and side navigation.
16///
17/// Compose with [`LayoutHeader`], [`LayoutSidebar`], and [`LayoutMain`] slots. Pair `overlay_header=true` with a Fixed or Sticky [`AppBar`](crate::AppBar) in the header slot.
18///
19/// # When to use
20///
21/// - Full-page application shells with header, optional side nav, and scrollable main content - Pinned sticky frost headers where page content scrolls beneath the bar - Opaque fixed headers where content and scrollbar start below the bar (inner scroll) - Inline header layouts without overlay chrome
22///
23/// # Usage
24///
25/// 1. Set `overlay_header=true` when the header uses Sticky or Fixed [`AppBar`]. 2. Use Sticky [`AppBar`] with frost material for the default pinned window-scroll shell. 3. Set `main_inset_scroll=true` for opaque fixed bars with inner main scroll below the bar. 4. Match `header_inset` density to the [`AppBar`] density tier. 5. Place navigation in [`LayoutSidebar`] and page content in [`LayoutMain`]. 6. [`LayoutSidebar`] stays pinned below the bar; only the page scrolls in pinned mode.
26///
27/// # Scroll modes
28///
29/// | Mode | Props | When |
30/// |------|-------|------|
31/// | Inline header | default | Header scrolls with page content |
32/// | Overlay pinned | `overlay_header=true`, sticky frost [`AppBar`] | Content scrolls beneath the bar |
33/// | Overlay inset | `overlay_header=true`, `main_inset_scroll=true`, opaque fixed [`AppBar`] | Main scrolls below the bar inside the shell |
34///
35/// # Best Practices
36///
37/// ## Do's
38///
39/// * Use [`MaterialElevation::Flat`] on shell chrome — borders separate regions, not shadows * Flat shell chrome in overlay layouts shares the layout canvas surface (`Background3`), not card fills * Use Sticky [`AppBar`] with Frost or Shell [`AppBarMaterial`] for pinned shells * Pair [`Navigation`] with `Solid` + `Flat` in [`LayoutSidebar`] for co-planar side nav * Use `main_inset_scroll=true` with Solid opaque fixed bars in bounded containers * Keep `header_inset` in sync with [`AppBar`] density * Put long scrolling content in [`LayoutMain`], not the layout root * Reserve `Resting` / `Raised` elevation for in-content cards and callouts, not shell regions
40///
41/// ## Don'ts
42///
43/// * Do not use Fixed AppBar with pinned overlay — use Sticky so the header row stays in document flow * Do not add manual top padding in main when the header sits above the body row * Do not use elevated Material tiers on AppBar or Navigation — use Flat with auto border separators
44///
45/// # Examples
46///
47/// ## Inline header
48/// Header and main in normal document flow—the default stacked shell.
49/// <!-- default -->
50/// <!-- preview -->
51/// ```rust
52/// use crate::{DemoBox, Layout, LayoutHeader, LayoutMain, Title3};
53/// view! {
54///     <div data-testid="layout-preview" style="height: 200px; border: 1px solid var(--orb-color-border-subtle);">
55///         <Layout>
56///             <LayoutHeader slot>
57///                 <DemoBox data_testid="layout-header-demo"><Title3>"Workspace"</Title3></DemoBox>
58///             </LayoutHeader>
59///             <LayoutMain slot>
60///                 <DemoBox fill=true data_testid="layout-main-demo">"Main content"</DemoBox>
61///             </LayoutMain>
62///         </Layout>
63///     </div>
64/// }
65/// ```
66///
67/// ## With sidebar
68/// Side navigation beside the primary content column.
69/// <!-- preview -->
70/// ```rust
71/// use crate::{DemoBox, Layout, LayoutMain, LayoutSidebar};
72/// view! {
73///     <div data-testid="layout-with-sidebar" style="height: 200px; border: 1px solid var(--orb-color-border-subtle);">
74///         <Layout>
75///             <LayoutSidebar slot>
76///                 <DemoBox fill=true data_testid="layout-sidebar-demo">"Nav"</DemoBox>
77///             </LayoutSidebar>
78///             <LayoutMain slot>
79///                 <DemoBox fill=true data_testid="layout-main-demo">"Main column"</DemoBox>
80///             </LayoutMain>
81///         </Layout>
82///     </div>
83/// }
84/// ```
85///
86/// ## Overlay pinned header
87/// Sticky frost header—content starts below the bar and scrolls beneath it via window scroll.
88/// <!-- preview -->
89/// ```rust
90/// use crate::{
91///     AppBar, AppBarLeading, AppBarMaterial, AppBarPosition, DemoBox, Layout, LayoutHeader, LayoutMain,
92///     MaterialCorners, MaterialElevation, MaterialVariant, Title3,
93/// };
94/// view! {
95///     <div data-testid="layout-overlay-header" style="height: 400px; border: 1px solid var(--orb-color-border-subtle); overflow: auto;">
96///         <Layout overlay_header=true page_scrollport=false>
97///             <LayoutHeader slot>
98///                 <AppBar position=AppBarPosition::Sticky>
99///                     <AppBarMaterial variant=MaterialVariant::Frost elevation=MaterialElevation::Flat corners=MaterialCorners::Square slot />
100///                     <AppBarLeading slot><Title3>"Overlay shell"</Title3></AppBarLeading>
101///                 </AppBar>
102///             </LayoutHeader>
103///             <LayoutMain slot>
104///                 <DemoBox height="1200px" data_testid="layout-scroll-content">
105///                     <p>"First line starts below the bar."</p>
106///                     <p>"Scroll to see content pass under the frosted header."</p>
107///                 </DemoBox>
108///             </LayoutMain>
109///         </Layout>
110///     </div>
111/// }
112/// ```
113///
114/// ## Overlay inset header
115/// Opaque fixed header—content and scrollbar start below the bar with no scroll-under.
116/// <!-- preview -->
117/// ```rust
118/// use crate::{
119///     AppBar, AppBarLeading, AppBarMaterial, AppBarPosition, DemoBox, Layout, LayoutHeader, LayoutMain,
120///     MaterialCorners, MaterialElevation, MaterialVariant, Title3,
121/// };
122/// view! {
123///     <div data-testid="layout-inset-header" style="height: 240px; border: 1px solid var(--orb-color-border-subtle); overflow: hidden;">
124///         <Layout overlay_header=true main_inset_scroll=true>
125///             <LayoutHeader slot>
126///                 <AppBar position=AppBarPosition::Fixed>
127///                     <AppBarMaterial variant=MaterialVariant::Solid elevation=MaterialElevation::Raised corners=MaterialCorners::Square slot />
128///                     <AppBarLeading slot><Title3>"Inset shell"</Title3></AppBarLeading>
129///                 </AppBar>
130///             </LayoutHeader>
131///             <LayoutMain slot>
132///                 <DemoBox height="400px" data_testid="layout-inset-content">
133///                     <p>"First line starts below the bar."</p>
134///                     <p>"Content stays below the opaque header when scrolling."</p>
135///                 </DemoBox>
136///             </LayoutMain>
137///         </Layout>
138///     </div>
139/// }
140/// ```
141///
142/// ## App shell
143/// Header, sidebar, and main—the catalog-style compound shell.
144/// <!-- preview -->
145/// ```rust
146/// use crate::{
147///     AppBar, AppBarLeading, AppBarMaterial, AppBarPosition, AppBarTrailing, Button,
148///     ButtonAppearance, DemoBox, Layout, LayoutHeader, LayoutMain, LayoutSidebar, MaterialCorners,
149///     MaterialElevation, MaterialVariant, Navigation, NavigationBody, NavigationConfig, NavigationItem,
150///     Title3,
151/// };
152/// let selected = RwSignal::new(None::<String>);
153/// let open = RwSignal::new(vec![] as Vec<String>);
154/// view! {
155///     <div data-testid="layout-app-shell" style="height: 260px; border: 1px solid var(--orb-color-border-subtle); overflow: auto;">
156///         <Layout overlay_header=true page_scrollport=false>
157///             <LayoutHeader slot>
158///                 <AppBar position=AppBarPosition::Sticky>
159///                     <AppBarMaterial variant=MaterialVariant::Frost elevation=MaterialElevation::Flat corners=MaterialCorners::Square slot />
160///                     <AppBarLeading slot><Title3>"Orbital Components"</Title3></AppBarLeading>
161///                     <AppBarTrailing slot>
162///                         <Button appearance=ButtonAppearance::Transparent icon=icondata::AiBulbOutlined />
163///                     </AppBarTrailing>
164///                 </AppBar>
165///             </LayoutHeader>
166///             <LayoutSidebar slot>
167///                 <Navigation config=NavigationConfig::new().with_selected_value(selected).with_open_categories(open)>
168///                     <NavigationBody slot>
169///                         <NavigationItem config="card" icon=icondata::AiAppstoreOutlined>"Card"</NavigationItem>
170///                     </NavigationBody>
171///                 </Navigation>
172///             </LayoutSidebar>
173///             <LayoutMain slot>
174///                 <DemoBox fill=true data_testid="layout-main-demo">"Preview outlet"</DemoBox>
175///             </LayoutMain>
176///         </Layout>
177///     </div>
178/// }
179/// ```
180///
181/// ## Sidebar toggle
182/// Coordinated sidebar open state via [`LayoutSidebarToggle`].
183/// <!-- preview -->
184/// ```rust
185/// use crate::{
186///     AppBar, AppBarLeading, AppBarMaterial, AppBarPosition, DemoBox, Layout, LayoutHeader, LayoutMain,
187///     LayoutSidebar, LayoutSidebarToggle, MaterialCorners, MaterialElevation, MaterialVariant,
188///     Navigation, NavigationBody, NavigationConfig, NavigationItem, Title3,
189/// };
190/// let sidebar_open = RwSignal::new(true);
191/// let selected = RwSignal::new(None::<String>);
192/// let open = RwSignal::new(vec![] as Vec<String>);
193/// view! {
194///     <div data-testid="layout-sidebar-toggle" style="height: 240px; border: 1px solid var(--orb-color-border-subtle); overflow: hidden;">
195///         <Layout overlay_header=true sidebar_open=sidebar_open>
196///             <LayoutHeader slot>
197///                 <AppBar position=AppBarPosition::Sticky>
198///                     <AppBarMaterial variant=MaterialVariant::Solid elevation=MaterialElevation::Flat corners=MaterialCorners::Square slot />
199///                     <AppBarLeading slot>
200///                         <LayoutSidebarToggle />
201///                         <Title3>"Shell"</Title3>
202///                     </AppBarLeading>
203///                 </AppBar>
204///             </LayoutHeader>
205///             <LayoutSidebar slot>
206///                 <Navigation config=NavigationConfig::new().with_open(Signal::derive(move || sidebar_open.get())).with_selected_value(selected).with_open_categories(open)>
207///                     <NavigationBody slot>
208///                         <NavigationItem config="home" icon=icondata::AiHomeOutlined>"Home"</NavigationItem>
209///                     </NavigationBody>
210///                 </Navigation>
211///             </LayoutSidebar>
212///             <LayoutMain slot>
213///                 <DemoBox fill=true data_testid="layout-main-demo">"Main content"</DemoBox>
214///             </LayoutMain>
215///         </Layout>
216///     </div>
217/// }
218/// ```
219///
220/// ## Sidebar closed
221/// Initial closed rail with expand affordance in the header.
222/// <!-- preview -->
223/// ```rust
224/// use crate::{
225///     AppBar, AppBarLeading, AppBarMaterial, AppBarPosition, DemoBox, Layout, LayoutHeader, LayoutMain,
226///     LayoutSidebar, LayoutSidebarToggle, MaterialCorners, MaterialElevation, MaterialVariant,
227///     Navigation, NavigationBody, NavigationConfig, NavigationItem, Title3,
228/// };
229/// let sidebar_open = RwSignal::new(false);
230/// let selected = RwSignal::new(None::<String>);
231/// let open = RwSignal::new(vec![] as Vec<String>);
232/// view! {
233///     <div data-testid="layout-sidebar-closed" style="height: 240px; border: 1px solid var(--orb-color-border-subtle); overflow: hidden;">
234///         <Layout overlay_header=true sidebar_open=sidebar_open>
235///             <LayoutHeader slot>
236///                 <AppBar position=AppBarPosition::Sticky>
237///                     <AppBarMaterial variant=MaterialVariant::Solid elevation=MaterialElevation::Flat corners=MaterialCorners::Square slot />
238///                     <AppBarLeading slot>
239///                         <LayoutSidebarToggle />
240///                         <Title3>"Collapsed rail"</Title3>
241///                     </AppBarLeading>
242///                 </AppBar>
243///             </LayoutHeader>
244///             <LayoutSidebar slot>
245///                 <Navigation config=NavigationConfig::new().with_open(Signal::derive(move || sidebar_open.get())).with_selected_value(selected).with_open_categories(open)>
246///                     <NavigationBody slot>
247///                         <NavigationItem config="home" icon=icondata::AiHomeOutlined>"Home"</NavigationItem>
248///                     </NavigationBody>
249///                 </Navigation>
250///             </LayoutSidebar>
251///             <LayoutMain slot>
252///                 <DemoBox fill=true data_testid="layout-main-demo">"Main content"</DemoBox>
253///             </LayoutMain>
254///         </Layout>
255///     </div>
256/// }
257/// ```
258#[component_doc(
259    category = "Shell",
260    preview_slug = "layout",
261    preview_label = "Layout",
262    preview_icon = icondata::AiLayoutOutlined,
263)]
264#[component]
265pub fn Layout(
266    /// Extra CSS class names merged onto the shell root grid.
267    #[prop(optional, into)]
268    class: MaybeProp<String>,
269    /// Optional `data-testid` for E2E hooks on the layout root.
270    #[prop(optional, into)]
271    data_testid: MaybeProp<String>,
272    /// When true, the header overlays main content and provides [`AppBarInset`] context.
273    #[prop(default = false)]
274    overlay_header: bool,
275    /// When true with `overlay_header`, main content scrolls inside an inset below the header.
276    #[prop(default = false)]
277    main_inset_scroll: bool,
278    /// When true (default), pinned overlay shells scroll via a full-viewport [`ScrollArea`] so Chrome applies themed scrollbar chrome. Set false for bounded preview hosts.
279    #[prop(default = true)]
280    page_scrollport: bool,
281    /// Header height token used for overlay inset padding — match the [`AppBar`] density.
282    #[prop(default = AppBarDensity::Standard)]
283    header_inset: AppBarDensity,
284    /// Parent-owned sidebar open state; defaults to an internal open signal when omitted.
285    #[prop(optional, into)]
286    sidebar_open: Option<RwSignal<bool>>,
287    /// Header slot — typically an [`AppBar`] with leading and trailing regions.
288    #[prop(optional)]
289    layout_header: Option<LayoutHeader>,
290    /// Sidebar slot — typically a [`Navigation`] rail beside main content.
291    #[prop(optional)]
292    layout_sidebar: Option<LayoutSidebar>,
293    /// Main content slot — page body rendered in the scrollable region.
294    #[prop(optional)]
295    layout_main: Option<LayoutMain>,
296) -> impl IntoView {
297    if overlay_header {
298        provide_context(AppBarInset {
299            height_px: header_inset.height_px(),
300        });
301        provide_context(LayoutOverlayScroll { main_inset_scroll });
302    }
303
304    let sidebar_open = sidebar_open.unwrap_or_else(|| RwSignal::new(true));
305    LayoutSidebarOpen::provide(sidebar_open);
306    let sidebar_open_signal = Signal::derive(move || sidebar_open.get());
307
308    let has_sidebar = layout_sidebar.is_some();
309    let inset_px = header_inset.height_px();
310    let root_style = Signal::derive(move || {
311        if overlay_header {
312            format!("--orbital-layout-header-inset: {inset_px}px;")
313        } else {
314            String::new()
315        }
316    });
317
318    let root_class = Signal::derive(move || {
319        let extra = class.get().unwrap_or_default();
320        let mut parts = Vec::new();
321        if has_sidebar {
322            parts.push("orbital-layout--has-sidebar".to_string());
323            if !sidebar_open_signal.get() {
324                parts.push("orbital-layout--sidebar-closed".to_string());
325            }
326        }
327        if overlay_header && main_inset_scroll {
328            parts.push("orbital-layout--inset-header".to_string());
329        }
330        if !extra.is_empty() {
331            parts.push(extra);
332        }
333        parts.join(" ")
334    });
335
336    let style_sheet = layout_styles();
337    let use_page_scrollport = overlay_header && !main_inset_scroll && page_scrollport;
338    let page_scroll_style = format!(
339        "display: block; width: 100%; height: 100%; box-sizing: border-box; \
340         --orbital-layout-header-inset: {inset_px}px; scroll-padding-top: {inset_px}px;"
341    );
342
343    let shell = view! {
344        <BaseLayout
345            class=root_class
346            style=root_style
347            data_testid=data_testid
348            overlay_header=overlay_header
349            position=LayoutPosition::Static
350        >
351            {layout_header.map(|slot| (slot.children)())}
352            <BaseLayoutBody>
353                {layout_sidebar.map(|slot| view! { <LayoutSidebarShell>{(slot.children)()}</LayoutSidebarShell> })}
354                {layout_main.map(|slot| view! { <LayoutMainShell>{(slot.children)()}</LayoutMainShell> })}
355            </BaseLayoutBody>
356        </BaseLayout>
357    };
358
359    view! {
360        <style>{style_sheet}</style>
361        {if use_page_scrollport {
362            view! {
363                <ScrollArea
364                    class="orbital-layout__page-scroll"
365                    style=page_scroll_style
366                >
367                    {shell}
368                </ScrollArea>
369            }
370            .into_any()
371        } else {
372            shell.into_any()
373        }}
374    }
375}