Skip to main content

tablo_ui/components/primitives/
sidebar.rs

1// SYNC: topcoat-ui-registry@0.10.0 sha256:08444f46d98808fcd942dac3db5a400d11116f38508aa3e2cd67c1ea6269a393 — do not hand-edit. Sync via `cargo xtask sync-topcoat-ui` (ADR-0007).
2use topcoat::{
3    Result,
4    icon::{icon, iconify::iconify_icon},
5    runtime::Expr,
6    view::{Attributes, Child, Class, StaticClass, View, attributes, class, component, view},
7};
8
9use super::{
10    button::{ButtonSize, ButtonVariant, button},
11    input::input,
12    separator::separator,
13    sheet::{SheetSide, sheet, sheet_content},
14    skeleton::skeleton,
15};
16
17/// The edge of the page occupied by a sidebar.
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19#[allow(dead_code)]
20pub enum SidebarSide {
21    #[default]
22    Left,
23    Right,
24}
25
26impl SidebarSide {
27    fn name(self) -> &'static str {
28        match self {
29            Self::Left => "left",
30            Self::Right => "right",
31        }
32    }
33
34    fn sheet(self) -> SheetSide {
35        match self {
36            Self::Left => SheetSide::Left,
37            Self::Right => SheetSide::Right,
38        }
39    }
40}
41
42/// The desktop sidebar's surface and its relationship to the page.
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44#[allow(dead_code)]
45pub enum SidebarVariant {
46    /// A panel separated from the page by a border.
47    #[default]
48    Sidebar,
49    /// A rounded panel with space around it.
50    Floating,
51    /// A sidebar beside a rounded [`sidebar_inset`] content surface.
52    Inset,
53}
54
55impl SidebarVariant {
56    fn name(self) -> &'static str {
57        match self {
58            Self::Sidebar => "sidebar",
59            Self::Floating => "floating",
60            Self::Inset => "inset",
61        }
62    }
63}
64
65/// How the desktop sidebar behaves when its `open` expression is false.
66#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
67#[allow(dead_code)]
68pub enum SidebarCollapsible {
69    /// Hide the panel and release its space to the page.
70    #[default]
71    Offcanvas,
72    /// Keep a narrow strip of menu icons visible.
73    Icon,
74    /// Keep the desktop panel expanded.
75    None,
76}
77
78impl SidebarCollapsible {
79    fn name(self) -> &'static str {
80        match self {
81            Self::Offcanvas => "offcanvas",
82            Self::Icon => "icon",
83            Self::None => "none",
84        }
85    }
86}
87
88/// The flex layout surrounding a sidebar and the page content.
89///
90/// On desktop, the page content scrolls within the viewport-height layout.
91///
92/// Set `--sidebar-width`, `--sidebar-width-mobile` and `--sidebar-width-icon`
93/// through `attrs` to customize the widths. Place a right sidebar after the
94/// page content. State belongs to the caller, which passes expressions to
95/// [`sidebar`] and event handlers to [`sidebar_trigger`] and [`sidebar_rail`].
96#[component]
97pub async fn sidebar_provider(
98    #[default] mut attrs: Attributes,
99    #[default] child: Child<'_>,
100) -> Result<impl View> {
101    Ok(view! {
102        <div
103            data-sidebar="provider"
104            class=(class!(
105                "flex min-h-svh w-full md:h-svh md:overflow-hidden [--sidebar-width:16rem] [--sidebar-width-mobile:18rem] [--sidebar-width-icon:3rem] md:has-[[data-variant=inset]]:bg-sidebar",
106                attrs.remove("class"),
107            ))
108            (attrs)
109        >
110            (child)
111        </div>
112    })
113}
114
115// Ordinary components nested in the panel inherit its palette too. The
116// scoped aliases keep buttons, inputs and captions independent of the page.
117const PANEL_THEME: StaticClass = class!(
118    "[--background:var(--sidebar)] [--foreground:var(--sidebar-foreground)] \
119     [--card:var(--sidebar)] [--card-foreground:var(--sidebar-foreground)] \
120     [--primary:var(--sidebar-primary)] \
121     [--primary-foreground:var(--sidebar-primary-foreground)] \
122     [--border:var(--sidebar-border)] [--ring:var(--sidebar-ring)] \
123     [--muted-foreground:color-mix(in_oklab,var(--sidebar-foreground)_70%,transparent)]",
124);
125
126/// A desktop panel that becomes a sheet over the page below `md` (48rem).
127///
128/// The `--sidebar-*` theme tokens control its colors independently of cards,
129/// sheets and page content. Nested controls inherit the sidebar palette.
130///
131/// `open` controls desktop expansion. `mobile_open` independently controls
132/// the mobile sheet, so it can start closed while desktop starts expanded.
133/// Pass runtime expressions reading the caller's signals to make both live.
134/// Header, content and footer children are rendered once and shared between
135/// viewport sizes, preserving their inputs and signal identities.
136///
137/// `attrs` apply to the outer wrapper. `sheet_attrs` apply to the sheet and
138/// accept a label, an ID, and event handlers for Escape or backdrop dismissal.
139/// Include a close button in the mobile header. Like [`sheet`], this is a
140/// visual overlay; focus trapping requires additional application scripting.
141#[component]
142pub async fn sidebar(
143    #[into]
144    #[default(true.into())]
145    open: Expr<bool>,
146    #[into]
147    #[default(false.into())]
148    mobile_open: Expr<bool>,
149    #[default] side: SidebarSide,
150    #[default] variant: SidebarVariant,
151    #[default] collapsible: SidebarCollapsible,
152    #[default] mut attrs: Attributes,
153    #[default] mut sheet_attrs: Attributes,
154    #[default] child: Child<'_>,
155) -> Result<impl View> {
156    let open = if collapsible == SidebarCollapsible::None {
157        true.into()
158    } else {
159        open
160    };
161    let collapse = collapsible.name();
162
163    Ok(view! {
164        <aside
165            data-sidebar="sidebar"
166            data-side=(side.name())
167            data-variant=(variant.name())
168            :data-state=$(if open { "expanded" } else { "collapsed" })
169            :data-collapsible=$(if open { "" } else { collapse })
170            class=(class!(
171                "group/sidebar relative w-0 shrink-0 text-sidebar-foreground md:sticky md:top-0 md:h-svh md:w-(--sidebar-width) md:self-start md:transition-[width] md:duration-200 md:data-[collapsible=offcanvas]:w-0 motion-reduce:transition-none",
172                if variant == SidebarVariant::Floating {
173                    "md:p-2 md:data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+1rem+2px)] md:data-[collapsible=offcanvas]:px-0"
174                } else {
175                    "md:data-[collapsible=icon]:w-(--sidebar-width-icon)"
176                },
177                "md:py-2" if variant == SidebarVariant::Inset,
178                attrs.remove("class"),
179            ))
180            (attrs)
181        >
182            sheet(
183                open: mobile_open,
184                attrs: attributes! {
185                    role="navigation"
186                    aria-label="Sidebar"
187                    class=(class!(
188                        "md:relative md:inset-auto md:flex md:overflow-visible md:bg-transparent md:opacity-100 md:backdrop-blur-none md:starting:open:opacity-100 md:transition-none md:group-data-[collapsible=offcanvas]/sidebar:invisible motion-reduce:transition-none",
189                        sheet_attrs.remove("class"),
190                    ))
191                    (sheet_attrs)
192                },
193                sheet_content(
194                    side: side.sheet(),
195                    attrs: attributes! {
196                        data-sidebar="panel"
197                        class=(class!(
198                            PANEL_THEME,
199                            "relative [&]:w-(--sidebar-width-mobile) [&]:max-w-[calc(100vw-3rem)] [&]:gap-0 [&]:overflow-hidden [&]:p-0 md:[&]:w-full md:[&]:max-w-none md:[&]:translate-x-0 md:[&]:transition-none motion-reduce:transition-none",
200                            match variant {
201                                SidebarVariant::Sidebar => "md:shadow-none",
202                                SidebarVariant::Floating => "md:rounded-xl md:border md:shadow-sm",
203                                SidebarVariant::Inset => "md:border-0 md:bg-transparent md:shadow-none",
204                            },
205                        ))
206                    },
207                    (child)
208                )
209            )
210        </aside>
211    })
212}
213
214/// A sidebar toggle button. Pass its signal update as an `@click` attribute.
215///
216/// `open` controls the accessible expanded state. Set `aria-controls` in
217/// `attrs` to the controlled panel's ID. Responsive triggers can use separate
218/// desktop and mobile expressions and handlers.
219#[component]
220pub async fn sidebar_trigger(
221    #[into]
222    #[default(true.into())]
223    open: Expr<bool>,
224    #[default] attrs: Attributes,
225) -> Result<impl View> {
226    Ok(view! {
227        button(
228            variant: ButtonVariant::Ghost,
229            size: ButtonSize::Icon,
230            attrs: attributes! {
231                type="button"
232                data-sidebar="trigger"
233                aria-label="Toggle sidebar"
234                :aria-expanded=$(if open { "true" } else { "false" })
235                (attrs)
236            },
237            icon(data: iconify_icon!("lucide:panel-left"))
238        )
239    })
240}
241
242/// A desktop edge button that toggles the sidebar on click.
243///
244/// Pass its signal update as an `@click` attribute.
245#[component]
246pub async fn sidebar_rail(
247    #[into]
248    #[default(true.into())]
249    open: Expr<bool>,
250    #[default] mut attrs: Attributes,
251) -> Result<impl View> {
252    Ok(view! {
253        <button
254            type="button"
255            data-sidebar="rail"
256            aria-label="Toggle sidebar"
257            title="Toggle sidebar"
258            :aria-expanded=$(if open { "true" } else { "false" })
259            class=(class!(
260                "absolute inset-y-0 z-10 hidden w-2 cursor-pointer outline-none after:absolute after:inset-y-0 after:w-px hover:after:bg-sidebar-border focus-visible:after:bg-sidebar-ring md:block group-data-[side=left]/sidebar:right-0 group-data-[side=left]/sidebar:after:right-0 group-data-[side=right]/sidebar:left-0 group-data-[side=right]/sidebar:after:left-0",
261                attrs.remove("class"),
262            ))
263            (attrs)
264        ></button>
265    })
266}
267
268/// The page content beside a sidebar, scrollable independently on desktop.
269///
270/// An immediate [`sidebar_header`] child becomes a sticky toolbar. Its height
271/// and bottom border match the sidebar header without extra classes.
272#[component]
273pub async fn sidebar_inset(
274    #[default] mut attrs: Attributes,
275    #[default] child: Child<'_>,
276) -> Result<impl View> {
277    Ok(view! {
278        <main
279            data-sidebar="inset"
280            class=(class!(
281                "relative flex min-w-0 flex-1 flex-col bg-background md:min-h-0 md:overflow-y-auto [&>[data-sidebar=header]]:sticky [&>[data-sidebar=header]]:top-0 [&>[data-sidebar=header]]:z-20 [&>[data-sidebar=header]]:flex-row [&>[data-sidebar=header]]:items-center [&>[data-sidebar=header]]:justify-start [&>[data-sidebar=header]]:gap-3 [&>[data-sidebar=header]]:bg-background [&>[data-sidebar=header]]:px-4 [&>[data-sidebar=header]>[aria-orientation=vertical]]:h-4 md:in-[[data-sidebar=provider]:has([data-variant=floating])]:my-[calc(--spacing(2)+1px)] md:in-[[data-sidebar=provider]:has([data-variant=inset])]:m-2 md:in-[[data-sidebar=provider]:has([data-variant=inset][data-side=left])]:ml-0 md:in-[[data-sidebar=provider]:has([data-variant=inset][data-side=right])]:mr-0 md:in-[[data-sidebar=provider]:has([data-variant=inset])]:rounded-xl md:in-[[data-sidebar=provider]:has([data-variant=inset])]:shadow-sm md:in-[[data-sidebar=provider]:has([data-variant=inset])]:ring-1 md:in-[[data-sidebar=provider]:has([data-variant=inset])]:ring-sidebar-border",
282                attrs.remove("class"),
283            ))
284            (attrs)
285        >
286            (child)
287        </main>
288    })
289}
290
291/// A header for the sidebar or its page content.
292///
293/// Its height and divider stay fixed when menu buttons collapse to icons.
294/// Inside [`sidebar_inset`], it lays out its children as a sticky toolbar.
295#[component]
296pub async fn sidebar_header(
297    #[default] mut attrs: Attributes,
298    #[default] child: Child<'_>,
299) -> Result<impl View> {
300    Ok(view! {
301        <div
302            data-sidebar="header"
303            class=(class!(
304                "flex h-14 shrink-0 flex-col justify-center gap-2 border-b border-border px-2",
305                attrs.remove("class"),
306            ))
307            (attrs)
308        >
309            (child)
310        </div>
311    })
312}
313
314/// The fixed footer below the sidebar's scrolling content.
315#[component]
316pub async fn sidebar_footer(
317    #[default] mut attrs: Attributes,
318    #[default] child: Child<'_>,
319) -> Result<impl View> {
320    Ok(view! {
321        <div
322            data-sidebar="footer"
323            class=(class!("flex shrink-0 flex-col gap-2 p-2", attrs.remove("class")))
324            (attrs)
325        >
326            (child)
327        </div>
328    })
329}
330
331/// The scrollable region between the header and footer.
332#[component]
333pub async fn sidebar_content(
334    #[default] mut attrs: Attributes,
335    #[default] child: Child<'_>,
336) -> Result<impl View> {
337    Ok(view! {
338        <div
339            data-sidebar="content"
340            class=(class!(
341                "flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto overscroll-contain",
342                attrs.remove("class"),
343            ))
344            (attrs)
345        >
346            (child)
347        </div>
348    })
349}
350
351/// A section containing a label, an optional action and related menu items.
352#[component]
353pub async fn sidebar_group(
354    #[default] mut attrs: Attributes,
355    #[default] child: Child<'_>,
356) -> Result<impl View> {
357    Ok(view! {
358        <div
359            data-sidebar="group"
360            class=(class!(
361                "relative flex min-w-0 flex-col gap-1 p-2",
362                attrs.remove("class"),
363            ))
364            (attrs)
365        >
366            (child)
367        </div>
368    })
369}
370
371/// A group's caption, hidden when the desktop sidebar collapses to icons.
372#[component]
373pub async fn sidebar_group_label(
374    #[default] mut attrs: Attributes,
375    #[default] child: Child<'_>,
376) -> Result<impl View> {
377    Ok(view! {
378        <div
379            data-sidebar="group-label"
380            class=(class!(
381                "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 md:group-data-[collapsible=icon]/sidebar:hidden",
382                attrs.remove("class"),
383            ))
384            (attrs)
385        >
386            (child)
387        </div>
388    })
389}
390
391// Shared button styles for group and menu actions. Each sets its own position.
392const ACTION: StaticClass = class!(
393    "absolute flex size-6 items-center justify-center rounded-md text-sidebar-foreground/70 outline-none hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring disabled:pointer-events-none disabled:opacity-50 md:group-data-[collapsible=icon]/sidebar:hidden [&_svg]:size-4",
394);
395
396/// An icon button beside the group label. Give it an accessible label.
397#[component]
398pub async fn sidebar_group_action(
399    #[default] mut attrs: Attributes,
400    #[default] child: Child<'_>,
401) -> Result<impl View> {
402    Ok(view! {
403        <button
404            type="button"
405            data-sidebar="group-action"
406            class=(class!(ACTION, "top-3 right-3", attrs.remove("class")))
407            (attrs)
408        >
409            (child)
410        </button>
411    })
412}
413
414/// The body of a sidebar group.
415#[component]
416pub async fn sidebar_group_content(
417    #[default] mut attrs: Attributes,
418    #[default] child: Child<'_>,
419) -> Result<impl View> {
420    Ok(view! {
421        <div
422            data-sidebar="group-content"
423            class=(class!("w-full text-sm", attrs.remove("class")))
424            (attrs)
425        >
426            (child)
427        </div>
428    })
429}
430
431/// A list of sidebar menu items.
432#[component]
433pub async fn sidebar_menu(
434    #[default] mut attrs: Attributes,
435    #[default] child: Child<'_>,
436) -> Result<impl View> {
437    Ok(view! {
438        <ul
439            data-sidebar="menu"
440            class=(class!("flex min-w-0 flex-col gap-1", attrs.remove("class")))
441            (attrs)
442        >
443            (child)
444        </ul>
445    })
446}
447
448/// One menu row containing a button or link and optional actions or submenus.
449#[component]
450pub async fn sidebar_menu_item(
451    #[default] mut attrs: Attributes,
452    #[default] child: Child<'_>,
453) -> Result<impl View> {
454    Ok(view! {
455        <li
456            data-sidebar="menu-item"
457            class=(class!("group/menu-item relative", attrs.remove("class")))
458            (attrs)
459        >
460            (child)
461        </li>
462    })
463}
464
465/// A text input styled for a sidebar header or group.
466#[component]
467pub async fn sidebar_input(#[default] mut attrs: Attributes) -> Result<impl View> {
468    Ok(view! {
469        input(
470            attrs: attributes! {
471                data-sidebar="input"
472                class=(class!(
473                    "bg-background md:group-data-[collapsible=icon]/sidebar:hidden",
474                    attrs.remove("class"),
475                ))
476                (attrs)
477            }
478        )
479    })
480}
481
482/// A rule between sidebar sections.
483#[component]
484pub async fn sidebar_separator(#[default] attrs: Attributes) -> Result<impl View> {
485    Ok(view! { separator(attrs: attributes! { data-sidebar="separator" (attrs) }) })
486}
487
488/// The visual style of a sidebar menu button.
489#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
490#[allow(dead_code)]
491pub enum SidebarMenuButtonVariant {
492    #[default]
493    Default,
494    Outline,
495}
496
497impl SidebarMenuButtonVariant {
498    fn classes(self) -> StaticClass {
499        match self {
500            Self::Default => class!("border-transparent"),
501            Self::Outline => class!("border-sidebar-border bg-sidebar shadow-xs"),
502        }
503    }
504}
505
506/// The size of a sidebar menu button.
507#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
508#[allow(dead_code)]
509pub enum SidebarMenuButtonSize {
510    Sm,
511    #[default]
512    Md,
513    Lg,
514}
515
516impl SidebarMenuButtonSize {
517    fn classes(self) -> StaticClass {
518        match self {
519            Self::Sm => class!("h-7 text-xs"),
520            Self::Md => class!("h-8 text-sm"),
521            Self::Lg => class!("h-12 text-sm"),
522        }
523    }
524}
525
526const MENU_BUTTON: StaticClass = class!(
527    "flex w-full min-w-0 items-center gap-2 overflow-hidden rounded-md border px-2 text-left outline-none transition-colors hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[active=true]:font-medium has-[+[data-sidebar=menu-action]]:pr-8 has-[+[data-sidebar=menu-badge]]:pr-8 [&>svg]:size-4 [&>svg]:shrink-0 [&>span:last-child]:truncate md:group-data-[collapsible=icon]/sidebar:size-8 md:group-data-[collapsible=icon]/sidebar:justify-center md:group-data-[collapsible=icon]/sidebar:p-0 md:group-data-[collapsible=icon]/sidebar:[&>span:last-child]:sr-only",
528);
529
530/// Classes for styling another element as a sidebar menu button.
531///
532/// Set `data-active="true"` for active styling. Put the label in a `<span>`
533/// after the icon so it remains accessible when the sidebar collapses.
534#[must_use]
535pub fn sidebar_menu_button_variants(
536    variant: SidebarMenuButtonVariant,
537    size: SidebarMenuButtonSize,
538) -> Class<(StaticClass, StaticClass, StaticClass)> {
539    class!(MENU_BUTTON, variant.classes(), size.classes())
540}
541
542/// A menu action, or a navigation link when `href` is supplied.
543///
544/// Put an icon before a `<span>` containing the label. `tooltip` supplies a
545/// native title hint for the icon-only state. `active` accepts a boolean or
546/// runtime expression. Event handlers and other attributes are forwarded.
547#[component]
548pub async fn sidebar_menu_button(
549    #[default] variant: SidebarMenuButtonVariant,
550    #[default] size: SidebarMenuButtonSize,
551    #[into]
552    #[default(false.into())]
553    active: Expr<bool>,
554    #[default] href: Option<&str>,
555    #[default] tooltip: Option<&str>,
556    #[default] mut attrs: Attributes,
557    #[default] child: Child<'_>,
558) -> Result<impl View> {
559    let attrs = attributes! {
560        data-sidebar="menu-button"
561        :data-active=$(if active { "true" } else { "false" })
562        :aria-current=$(active.then_some("page"))
563        title=(tooltip)
564        class=(class!(
565            sidebar_menu_button_variants(variant, size),
566            attrs.remove("class"),
567        ))
568        (attrs)
569    };
570
571    Ok(view! {
572        if let Some(href) = href {
573            <a href=(href) (attrs)>(child)</a>
574        } else {
575            <button type="button" (attrs)>(child)</button>
576        }
577    })
578}
579
580/// An independent action beside a menu button. Give it an accessible label.
581#[component]
582pub async fn sidebar_menu_action(
583    #[default] show_on_hover: bool,
584    #[default] mut attrs: Attributes,
585    #[default] child: Child<'_>,
586) -> Result<impl View> {
587    Ok(view! {
588        <button
589            type="button"
590            data-sidebar="menu-action"
591            class=(class!(
592                ACTION,
593                "top-1 right-1",
594                "md:opacity-0 md:group-hover/menu-item:opacity-100 md:group-focus-within/menu-item:opacity-100" if show_on_hover,
595                attrs.remove("class"),
596            ))
597            (attrs)
598        >
599            (child)
600        </button>
601    })
602}
603
604/// A count or status beside a menu button.
605#[component]
606pub async fn sidebar_menu_badge(
607    #[default] mut attrs: Attributes,
608    #[default] child: Child<'_>,
609) -> Result<impl View> {
610    Ok(view! {
611        <span
612            data-sidebar="menu-badge"
613            class=(class!(
614                "pointer-events-none absolute top-1 right-1 flex h-6 min-w-6 items-center justify-center rounded-md px-1 text-xs tabular-nums text-sidebar-foreground/70 md:group-data-[collapsible=icon]/sidebar:hidden",
615                attrs.remove("class"),
616            ))
617            (attrs)
618        >
619            (child)
620        </span>
621    })
622}
623
624/// A placeholder matching a menu row, optionally including an icon.
625#[component]
626pub async fn sidebar_menu_skeleton(
627    #[default] show_icon: bool,
628    #[default] mut attrs: Attributes,
629) -> Result<impl View> {
630    Ok(view! {
631        <div
632            data-sidebar="menu-skeleton"
633            aria-hidden="true"
634            class=(class!(
635                "flex h-8 items-center gap-2 rounded-md px-2",
636                attrs.remove("class"),
637            ))
638            (attrs)
639        >
640            if show_icon {
641                skeleton(attrs: attributes! { class="size-4 shrink-0" })
642            }
643            skeleton(
644                attrs: attributes! {
645                    class="h-4 w-3/4 md:group-data-[collapsible=icon]/sidebar:hidden"
646                }
647            )
648        </div>
649    })
650}
651
652/// A nested menu, hidden when the sidebar collapses to icons.
653#[component]
654pub async fn sidebar_menu_sub(
655    #[default] mut attrs: Attributes,
656    #[default] child: Child<'_>,
657) -> Result<impl View> {
658    Ok(view! {
659        <ul
660            data-sidebar="menu-sub"
661            class=(class!(
662                "mx-3.5 flex min-w-0 flex-col gap-1 border-l border-sidebar-border px-2.5 py-1 md:group-data-[collapsible=icon]/sidebar:hidden",
663                attrs.remove("class"),
664            ))
665            (attrs)
666        >
667            (child)
668        </ul>
669    })
670}
671
672/// One row of a nested sidebar menu.
673#[component]
674pub async fn sidebar_menu_sub_item(
675    #[default] mut attrs: Attributes,
676    #[default] child: Child<'_>,
677) -> Result<impl View> {
678    Ok(view! {
679        <li
680            data-sidebar="menu-sub-item"
681            class=(class!("relative", attrs.remove("class")))
682            (attrs)
683        >
684            (child)
685        </li>
686    })
687}
688
689/// A nested menu link. Pass its destination through the `href` attribute.
690#[component]
691pub async fn sidebar_menu_sub_button(
692    #[into]
693    #[default(false.into())]
694    active: Expr<bool>,
695    #[default] size: SidebarMenuButtonSize,
696    #[default] mut attrs: Attributes,
697    #[default] child: Child<'_>,
698) -> Result<impl View> {
699    Ok(view! {
700        <a
701            data-sidebar="menu-sub-button"
702            :aria-current=$(active.then_some("page"))
703            class=(class!(
704                "flex min-w-0 items-center gap-2 rounded-md px-2 text-sidebar-foreground/70 outline-none hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring aria-[current=page]:bg-sidebar-accent aria-[current=page]:text-sidebar-accent-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0 [&>span:last-child]:truncate",
705                size.classes(),
706                attrs.remove("class"),
707            ))
708            (attrs)
709        >
710            (child)
711        </a>
712    })
713}