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        // The user and tenant menus, present only with a session (ADR-0013). `ensure_token` runs
192        // before any streaming starts so their forms, and the language menu's, always carry a
193        // matching CSRF pair.
194        let (user_menu, tenant_menu): (BoxView<'_>, BoxView<'_>) = match crate::auth::signed(cx) {
195            Some(signed) => {
196                let csrf = crate::csrf::ensure_token(cx);
197                let items: Vec<NavigationItem> = user_menu::account_items(cx)
198                    .into_iter()
199                    .chain(
200                        current(cx)
201                            .map(|panel| panel.user_menu.clone())
202                            .unwrap_or_default(),
203                    )
204                    .collect();
205                (
206                    Self::user_menu(
207                        cx,
208                        signed.user.as_ref(),
209                        &items,
210                        crate::auth::logout_url(cx),
211                        &csrf,
212                    ),
213                    Self::tenant_menu(cx, signed.user.tenants(), &csrf),
214                )
215            }
216            // Without a session the footer still offers the theme, and the language where the
217            // panel has one to pick: only that menu's forms need a token here.
218            None => {
219                let csrf = if crate::i18n::offered(cx).len() > 1 {
220                    crate::csrf::ensure_token(cx)
221                } else {
222                    String::new()
223                };
224                (Self::theme_menu(cx, &csrf), ().boxed())
225            }
226        };
227        let toaster = Self::toaster(cx).await?;
228
229        Ok(view! {
230            sidebar_provider(
231                attrs: attributes! { class=(outer_class) },
232                // No `sidebar_rail`: the header's `sidebar_trigger` is the one toggle.
233                sidebar(
234                    open: $(sidebar_open.get()),
235                    mobile_open: $(mobile_open.get()),
236                    collapsible: SidebarCollapsible::Icon,
237                    variant: SidebarVariant::Inset,
238                    sheet_attrs: attributes! {
239                        id="mobile-sidebar-sheet"
240                        aria-label=(crate::t!(cx, "tablo-shell-navigation"))
241                        @keydown=$(|e: Event| {
242                            if e.key == "Escape" {
243                                mobile_open.set(false);
244                            }
245                        })
246                        @click=$(|e: Event| {
247                            if e.target.id == "mobile-sidebar-sheet" {
248                                mobile_open.set(false);
249                            }
250                        })
251                    },
252                    sidebar_header(
253                        // Collapsed to icons, the rail leaves the brand only its mark: centred,
254                        // it lines up with the menu icons below.
255                        <div
256                            class="flex items-center gap-2 px-2 md:group-data-[collapsible=icon]/sidebar:justify-center md:group-data-[collapsible=icon]/sidebar:px-0"
257                        >
258                            (sidebar_brand)
259                            // Below `md` the sheet covers the inset header,
260                            // so the sheet's own header carries the close
261                            // control (upstream `examples/ui`); the mobile
262                            // trigger in the inset header is behind the veil.
263                            tablo_ui::button(
264                                variant: tablo_ui::ButtonVariant::Ghost,
265                                size: tablo_ui::ButtonSize::Icon,
266                                attrs: attributes! {
267                                    type="button"
268                                    class="md:hidden ml-auto"
269                                    aria-label=(crate::t!(cx, "tablo-shell-close-sidebar"))
270                                    @click=$(|_e: Event| mobile_open.set(false))
271                                },
272                                icon(data: tablo_ui::icons::X)
273                            )
274                        </div>
275                    )
276                    sidebar_content(
277                        // The icon rail is too narrow for a scrollbar: it still scrolls, without
278                        // one.
279                        attrs: attributes! {
280                            class="md:group-data-[collapsible=icon]/sidebar:[scrollbar-width:none]"
281                        },
282                        (tenant_menu)
283                        (back)
284                        (navigation)
285                    )
286                    sidebar_footer((user_menu))
287                )
288                sidebar_inset(
289                    sidebar_header(
290                        // The desktop trigger collapses the rail; below md the
291                        // mobile trigger opens the sheet instead (shadcn
292                        // SidebarTrigger pair, upstream `examples/ui`).
293                        sidebar_trigger(
294                            open: $(sidebar_open.get()),
295                            attrs: attributes! {
296                                class="max-md:hidden"
297                                aria-controls="mobile-sidebar-sheet"
298                                @click=$(|_e: Event| sidebar_open.toggle())
299                            }
300                        )
301                        sidebar_trigger(
302                            open: $(mobile_open.get()),
303                            attrs: attributes! {
304                                class="md:hidden"
305                                aria-controls="mobile-sidebar-sheet"
306                                @click=$(|_e: Event| mobile_open.toggle())
307                            }
308                        )
309                        // The sidebar carries the brand on desktop; below md it hides in
310                        // the sheet, so the topbar names the panel there.
311                        <div
312                            class="min-w-0 truncate font-semibold text-foreground md:hidden"
313                        >
314                            (header_title)
315                        </div>
316                        // The separator stays a direct child of the topbar,
317                        // where `sidebar_inset` sizes a vertical rule.
318                        separator(
319                            orientation: SeparatorOrientation::Vertical,
320                            attrs: attributes! { class="max-sm:hidden" }
321                        )
322                        (crumbs)
323                        (super::search::topbar_end(cx))
324                    )
325                    // `sidebar_inset` is the document's one `<main>`; a second
326                    // nested landmark is invalid and confuses landmark
327                    // navigation (upstream `examples/ui` uses a plain div).
328                    // The page container (`tablo_ui::page`) owns the width
329                    // and the padding.
330                    (super::impersonation::banner(cx))
331                    <div class="flex flex-1 flex-col">(slot)</div>
332                )
333                (toaster)
334            ) // Scripts are owned by the document (layout_shell).
335        }
336        .boxed())
337    }
338
339    /// The panel shell around a page, as a complete HTML document: the layout
340    /// every panel registers at its prefix unless [`Panel::layout`] replaces
341    /// it, and what a replacement calls to keep the shell around its own
342    /// markup.
343    ///
344    /// The stylesheet and font are supplied to the Panel builder with
345    /// [`Self::shell_assets`]. A Panel without those values remains renderable
346    /// for tests and custom document owners, but does not pretend that a CSS
347    /// bundle exists. Errors from the page slot propagate unchanged when the
348    /// document view is resolved.
349    pub fn layout_shell<'a>(cx: &'a Cx, slot: Slot<'a>) -> BoxView<'a> {
350        Box::pin(ThenView::new(Self::render_layout_shell(cx, slot)))
351    }
352
353    async fn render_layout_shell<'a>(cx: &'a Cx, slot: Slot<'a>) -> Result<BoxView<'a>> {
354        // A page a creatable choice's dialog frames shows only itself and its toasts.
355        if super::url::picking(cx) {
356            let body = async_page(async move {
357                Ok(view! {
358                    // `main` paints the frame; the block inside it is what the frame fits.
359                    <main class="min-h-dvh bg-card text-card-foreground">
360                        <div class="px-6 pt-6 pb-6" data-pick-page="">(slot)</div>
361                    </main>
362                    (Self::toaster(cx).await?)
363                }
364                .boxed())
365            });
366            let panel = current(cx).map(|panel| &**panel);
367            return Self::render_document(cx, panel, Self::brand_name(cx), body).await;
368        }
369        // The browser's URL, not a rewrite's: the error page names where the visitor is.
370        let path = topcoat::router::request::original_uri(cx)
371            .path()
372            .to_string();
373        // The sidebar of the area the path is under, else the panel's, or a single Home entry
374        // outside any panel.
375        let panel = current(cx);
376        let entries: Vec<_> = panel
377            .map(|panel| area::listed(cx, panel, panel.area_at(&path)))
378            .unwrap_or_default();
379        // One count per badge, run together rather than one after another.
380        let badges = futures_util::future::join_all(entries.iter().map(|entry| async {
381            match &entry.badge {
382                Some(badge) => Some(badge(cx).await),
383                None => None,
384            }
385        }))
386        .await;
387        crate::notification::prefetch_bell(cx).await;
388        let mut nav_items: Vec<NavigationItem> = entries
389            .iter()
390            .zip(badges)
391            .map(|(entry, badge)| {
392                let mut item = area::item(cx, entry);
393                if let Some(badge) = badge {
394                    item.badge = badge;
395                }
396                item
397            })
398            .collect();
399        if nav_items.is_empty() {
400            nav_items.push(NavigationItem::at(
401                crate::t!(cx, "tablo-shell-home"),
402                super::gate::panel_prefix(cx),
403            ));
404        }
405        let shell = Self::render_shell(cx, &nav_items, &path, slot, None).await?;
406        Self::render_document(
407            cx,
408            current(cx).map(|panel| &**panel),
409            Self::brand_name(cx),
410            shell,
411        )
412        .await
413    }
414}
415
416mod area;
417mod brand;
418mod breadcrumbs;
419mod document;
420mod environment;
421mod language;
422pub(super) mod sidebar;
423mod theme;
424mod toaster;
425mod user_menu;
426
427pub(in crate::panel) use area::area_entries;
428pub use brand::{Brand, Radius};
429pub use environment::Environment;
430pub(crate) use language::{LANGUAGE_SEGMENT, language_post};
431use theme::{SIDEBAR_COOKIE, Theme};
432
433use crate::cookie::request_cookie;