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}