Skip to main content

tablo_core/panel/
shell.rs

1//! Renders the shell framing every admin page.
2
3use topcoat::{
4    Result,
5    asset::Asset,
6    context::Cx,
7    font::Font,
8    icon::icon,
9    router::Slot,
10    runtime::{Event, Signal, signal},
11    view::{Attributes, BoxView, Child, ViewExt, attributes, internal::ThenView, view},
12};
13
14use super::{
15    Panel,
16    state::{ResourceTrail, current},
17};
18use crate::{navigation::NavigationItem, topcoat_compat::async_page};
19
20/// `extra` plus the attribute that sends a sidebar link through runtime
21/// navigation. The menu button writes the `href` itself, so this carries none.
22fn sidebar_link(cx: &Cx, mut extra: Attributes) -> Attributes {
23    let mut attrs = crate::navigation::runtime_link(cx, "");
24    attrs.remove("href");
25    extra.extend(attrs);
26    extra
27}
28
29/// The application-owned assets used by [`Panel::layout_shell`].
30///
31/// Tailwind's generated stylesheet is necessarily a call-site asset because
32/// every application scans a different source tree. The Panel therefore takes
33/// the generated stylesheet and the application's chosen font as values while
34/// still owning the document markup that links them.
35#[derive(Debug, Clone, Copy)]
36pub(crate) struct ShellAssets {
37    pub(crate) stylesheet: Asset,
38    pub(crate) font: Font,
39}
40
41impl Panel {
42    async fn sidebar_navigation<'a>(
43        cx: &'a Cx,
44        nav_items: &[NavigationItem],
45        current_path: &str,
46        current_query: &str,
47        mobile_open: Signal<bool>,
48    ) -> Result<BoxView<'a>> {
49        use tablo_ui::{
50            sidebar_group, sidebar_group_content, sidebar_group_label, sidebar_menu,
51            sidebar_menu_badge, sidebar_menu_button, sidebar_menu_item,
52        };
53        // A Panel resolves every item it owns (`Panel::resource`, `page`, `home`); one that
54        // reaches the sidebar unresolved has no URL to render: a framework bug, not user error.
55        debug_assert!(
56            nav_items.iter().all(|item| item.url().is_some()),
57            "navigation items are resolved by the Panel that owns them"
58        );
59        let nav_items = sidebar::in_sidebar_order(nav_items);
60        // One active entry, chosen the way the breadcrumbs choose theirs.
61        let active = crate::navigation::current_index(&nav_items, current_path, current_query);
62        let sections = sidebar::sidebar_sections(nav_items);
63
64        Ok(view! {
65            for (label, entries) in sections {
66                sidebar_group(
67                    sidebar_group_label((label))
68                    sidebar_group_content(
69                        sidebar_menu(
70                            for (index, item) in entries {
71                                let is_active = active == Some(index);
72                                let label = item.label.localize(cx);
73                                let attrs = sidebar_link(
74                                    cx,
75                                    attributes! {
76                                        // Tapping a link in the mobile sheet closes
77                                        // it; on desktop the navigation is the effect.
78                                        @click=$(|_e: Event| mobile_open.set(false))
79                                    },
80                                );
81                                sidebar_menu_item(
82                                    sidebar_menu_button(
83                                        active: is_active,
84                                        href: item.url(),
85                                        tooltip: Some(label.as_str()),
86                                        attrs: attrs,
87                                        if let Some(data) = item.icon.clone() {
88                                            icon(data: data)
89                                        }
90                                        <span>(label.clone())</span>
91                                    )
92                                    if let Some(badge) = item.badge.clone() {
93                                        sidebar_menu_badge((badge))
94                                    }
95                                )
96                            }
97                        )
98                    )
99                )
100            }
101        }
102        .boxed())
103    }
104
105    /// Whether the persisted [`SIDEBAR_COOKIE`] asks for an expanded
106    /// desktop sidebar (default: expanded).
107    ///
108    /// The value only seeds the runtime
109    /// signal's initial `data-state`; after hydration the browser owns the
110    /// state, and `assets/sidebar.js` mirrors changes back to the cookie.
111    fn sidebar_starts_open(cx: &Cx) -> bool {
112        request_cookie(cx, SIDEBAR_COOKIE).as_deref() != Some("collapsed")
113    }
114
115    /// Renders the shell around `slot` with an optional outer class.
116    pub async fn render_shell<'a>(
117        cx: &'a Cx,
118        nav_items: &[NavigationItem],
119        current_path: &str,
120        slot: Child<'a>,
121        extra_class: Option<String>,
122    ) -> Result<BoxView<'a>> {
123        // A hoisting body: the sidebar signals are declared while this view
124        // resolves, and a page re-run resumes them from the client. The owned
125        // copies pin the caller's navigation to the lazy body's lifetime.
126        let nav_items = nav_items.to_vec();
127        let current_path = current_path.to_string();
128        Ok(async_page(async move {
129            Self::render_shell_body(cx, &nav_items, &current_path, slot, extra_class).await
130        }))
131    }
132
133    async fn render_shell_body<'a>(
134        cx: &'a Cx,
135        nav_items: &[NavigationItem],
136        current_path: &str,
137        slot: Child<'a>,
138        extra_class: Option<String>,
139    ) -> Result<BoxView<'a>> {
140        use tablo_ui::{
141            SeparatorOrientation, SidebarCollapsible, SidebarVariant, separator, sidebar,
142            sidebar_content, sidebar_footer, sidebar_header, sidebar_inset, sidebar_provider,
143            sidebar_trigger,
144        };
145
146        let sidebar_open = signal(cx, || Self::sidebar_starts_open(cx));
147        let mobile_open = signal(cx, || false);
148        let outer_class = extra_class.clone().unwrap_or_default();
149        let header_title = Self::brand_name(cx);
150        // One navigation tree: the upstream sidebar renders its children once
151        // and shares them between the desktop panel and the mobile sheet.
152        // The query the browser sent: an entry that names parameters the request carries wins
153        // over one that names none, at the same path.
154        let current_query = topcoat::router::request::original_uri(cx)
155            .query()
156            .unwrap_or_default();
157        let navigation = Self::sidebar_navigation(
158            cx,
159            nav_items,
160            current_path,
161            current_query,
162            mobile_open.clone(),
163        )
164        .await?;
165        let sidebar_brand = Self::render_brand(cx).await?;
166        let resources: Vec<&ResourceTrail> = current(cx)
167            .map(|panel| {
168                panel
169                    .nav_items
170                    .iter()
171                    .filter_map(|entry| entry.resource_list.as_ref())
172                    .collect()
173            })
174            .unwrap_or_default();
175        let trail = match super::error_page::current_error(cx) {
176            Some(error) => {
177                breadcrumbs::failed(nav_items, current_path, current_query, error.title())
178            }
179            None => breadcrumbs::trail(nav_items, &resources, current_path, current_query),
180        };
181        let area = current(cx).and_then(|panel| {
182            let area = panel.area_at(current_path)?;
183            Some((panel.areas[area].clone(), panel.realm.prefix.clone()))
184        });
185        let trail = match &area {
186            Some((area, _)) => breadcrumbs::within(area.label.clone(), &area.url, trail),
187            None => trail,
188        };
189        let back = area.map_or_else(|| ().boxed(), |(_, home)| area::back_link(cx, &home));
190        let crumbs = breadcrumbs::render(cx, trail);
191        let banners: Vec<BoxView<'_>> = current(cx)
192            .map(|panel| panel.banners.iter().map(|banner| banner(cx)).collect())
193            .unwrap_or_default();
194        // The user and tenant menus, present only with a session (ADR-0013). `ensure_token` runs
195        // before any streaming starts so their forms, and the language menu's, always carry a
196        // matching CSRF pair.
197        let (user_menu, tenant_menu): (BoxView<'_>, BoxView<'_>) = match crate::auth::signed(cx) {
198            Some(signed) => {
199                let csrf = crate::csrf::ensure_token(cx);
200                let items: Vec<NavigationItem> = user_menu::account_items(cx)
201                    .into_iter()
202                    .chain(
203                        current(cx)
204                            .map(|panel| panel.user_menu.clone())
205                            .unwrap_or_default(),
206                    )
207                    .collect();
208                (
209                    Self::user_menu(
210                        cx,
211                        signed.user.as_ref(),
212                        &items,
213                        crate::auth::logout_url(cx),
214                        &csrf,
215                    ),
216                    Self::tenant_menu(cx, signed.user.tenants(), &csrf),
217                )
218            }
219            // Without a session the footer still offers the theme, and the language where the
220            // panel has one to pick: only that menu's forms need a token here.
221            None => {
222                let csrf = if crate::i18n::offered(cx).len() > 1 {
223                    crate::csrf::ensure_token(cx)
224                } else {
225                    String::new()
226                };
227                (Self::theme_menu(cx, &csrf), ().boxed())
228            }
229        };
230        let toaster = Self::toaster(cx).await?;
231
232        Ok(view! {
233            sidebar_provider(
234                attrs: attributes! { class=(outer_class) },
235                // No `sidebar_rail`: the header's `sidebar_trigger` is the one toggle.
236                sidebar(
237                    open: $(sidebar_open.get()),
238                    mobile_open: $(mobile_open.get()),
239                    collapsible: SidebarCollapsible::Icon,
240                    variant: SidebarVariant::Inset,
241                    sheet_attrs: attributes! {
242                        id="mobile-sidebar-sheet"
243                        aria-label=(crate::t!(cx, "tablo-shell-navigation"))
244                        @keydown=$(|e: Event| {
245                            if e.key == "Escape" {
246                                mobile_open.set(false);
247                            }
248                        })
249                        @click=$(|e: Event| {
250                            if e.target.id == "mobile-sidebar-sheet" {
251                                mobile_open.set(false);
252                            }
253                        })
254                    },
255                    sidebar_header(
256                        // Collapsed to icons, the rail leaves the brand only its mark: centred,
257                        // it lines up with the menu icons below.
258                        <div
259                            class="flex items-center gap-2 px-2 md:group-data-[collapsible=icon]/sidebar:justify-center md:group-data-[collapsible=icon]/sidebar:px-0"
260                        >
261                            (sidebar_brand)
262                            // Below `md` the sheet covers the inset header,
263                            // so the sheet's own header carries the close
264                            // control (upstream `examples/ui`); the mobile
265                            // trigger in the inset header is behind the veil.
266                            tablo_ui::button(
267                                variant: tablo_ui::ButtonVariant::Ghost,
268                                size: tablo_ui::ButtonSize::Icon,
269                                attrs: attributes! {
270                                    type="button"
271                                    class="md:hidden ml-auto"
272                                    aria-label=(crate::t!(cx, "tablo-shell-close-sidebar"))
273                                    @click=$(|_e: Event| mobile_open.set(false))
274                                },
275                                icon(data: tablo_ui::icons::X)
276                            )
277                        </div>
278                    )
279                    sidebar_content(
280                        // The icon rail is too narrow for a scrollbar: it still scrolls, without
281                        // one.
282                        attrs: attributes! {
283                            class="md:group-data-[collapsible=icon]/sidebar:[scrollbar-width:none]"
284                        },
285                        (tenant_menu)
286                        (back)
287                        (navigation)
288                    )
289                    sidebar_footer((user_menu))
290                )
291                sidebar_inset(
292                    sidebar_header(
293                        // The desktop trigger collapses the rail; below md the
294                        // mobile trigger opens the sheet instead (shadcn
295                        // SidebarTrigger pair, upstream `examples/ui`).
296                        sidebar_trigger(
297                            open: $(sidebar_open.get()),
298                            attrs: attributes! {
299                                class="max-md:hidden"
300                                aria-controls="mobile-sidebar-sheet"
301                                @click=$(|_e: Event| sidebar_open.toggle())
302                            }
303                        )
304                        sidebar_trigger(
305                            open: $(mobile_open.get()),
306                            attrs: attributes! {
307                                class="md:hidden"
308                                aria-controls="mobile-sidebar-sheet"
309                                @click=$(|_e: Event| mobile_open.toggle())
310                            }
311                        )
312                        // The sidebar carries the brand on desktop; below md it hides in
313                        // the sheet, so the topbar names the panel there.
314                        <div
315                            class="min-w-0 truncate font-semibold text-foreground md:hidden"
316                        >
317                            (header_title)
318                        </div>
319                        // The separator stays a direct child of the topbar,
320                        // where `sidebar_inset` sizes a vertical rule.
321                        separator(
322                            orientation: SeparatorOrientation::Vertical,
323                            attrs: attributes! { class="max-sm:hidden" }
324                        )
325                        (crumbs)
326                        (super::search::topbar_end(cx))
327                    )
328                    // `sidebar_inset` is the document's one `<main>`; a second
329                    // nested landmark is invalid and confuses landmark
330                    // navigation (upstream `examples/ui` uses a plain div).
331                    // The page container (`tablo_ui::page`) owns the width
332                    // and the padding.
333                    (super::impersonation::banner(cx))
334                    for banner in banners {
335                        (banner)
336                    }
337                    <div class="flex flex-1 flex-col">(slot)</div>
338                )
339                (toaster)
340            ) // Scripts are owned by the document (layout_shell).
341        }
342        .boxed())
343    }
344
345    /// The panel shell around a page, as a complete HTML document: the layout
346    /// every panel registers at its prefix unless [`Panel::layout`] replaces
347    /// it, and what a replacement calls to keep the shell around its own
348    /// markup.
349    ///
350    /// The stylesheet and font are supplied to the Panel builder with
351    /// [`Self::shell_assets`]. A Panel without those values remains renderable
352    /// for tests and custom document owners, but does not pretend that a CSS
353    /// bundle exists. Errors from the page slot propagate unchanged when the
354    /// document view is resolved.
355    pub fn layout_shell<'a>(cx: &'a Cx, slot: Slot<'a>) -> BoxView<'a> {
356        Box::pin(ThenView::new(Self::render_layout_shell(cx, slot)))
357    }
358
359    async fn render_layout_shell<'a>(cx: &'a Cx, slot: Slot<'a>) -> Result<BoxView<'a>> {
360        // A page a creatable choice's dialog frames shows only itself and its toasts.
361        if super::url::picking(cx) {
362            let body = async_page(async move {
363                Ok(view! {
364                    // `main` paints the frame; the block inside it is what the frame fits.
365                    <main class="min-h-dvh bg-card text-card-foreground">
366                        <div class="px-6 pt-6 pb-6" data-pick-page="">(slot)</div>
367                    </main>
368                    (Self::toaster(cx).await?)
369                }
370                .boxed())
371            });
372            let panel = current(cx).map(|panel| &**panel);
373            return Self::render_document(cx, panel, Self::brand_name(cx), body).await;
374        }
375        // The browser's URL, not a rewrite's: the error page names where the visitor is.
376        let path = topcoat::router::request::original_uri(cx)
377            .path()
378            .to_string();
379        // The sidebar of the area the path is under, else the panel's, or a single Home entry
380        // outside any panel.
381        let panel = current(cx);
382        let entries: Vec<_> = panel
383            .map(|panel| area::listed(cx, panel, panel.area_at(&path)))
384            .unwrap_or_default();
385        // One count per badge, run together rather than one after another.
386        let badges = futures_util::future::join_all(entries.iter().map(|entry| async {
387            match &entry.badge {
388                Some(badge) => Some(badge(cx).await),
389                None => None,
390            }
391        }))
392        .await;
393        crate::notification::prefetch_bell(cx).await;
394        let mut nav_items: Vec<NavigationItem> = entries
395            .iter()
396            .zip(badges)
397            .map(|(entry, badge)| {
398                let mut item = area::item(cx, entry);
399                if let Some(badge) = badge {
400                    item.badge = badge;
401                }
402                item
403            })
404            .collect();
405        if nav_items.is_empty() {
406            nav_items.push(NavigationItem::at(
407                crate::t!(cx, "tablo-shell-home"),
408                super::gate::panel_prefix(cx),
409            ));
410        }
411        let shell = Self::render_shell(cx, &nav_items, &path, slot, None).await?;
412        Self::render_document(
413            cx,
414            current(cx).map(|panel| &**panel),
415            Self::brand_name(cx),
416            shell,
417        )
418        .await
419    }
420}
421
422mod area;
423mod brand;
424mod breadcrumbs;
425mod document;
426mod environment;
427mod language;
428pub(super) mod sidebar;
429mod theme;
430mod toaster;
431mod user_menu;
432
433pub(in crate::panel) use area::area_entries;
434pub use brand::{Brand, Radius};
435pub use environment::Environment;
436pub(crate) use language::{LANGUAGE_SEGMENT, language_post};
437use theme::{SIDEBAR_COOKIE, Theme};
438
439use crate::cookie::request_cookie;