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, ¤t_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;