1use 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#[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44#[allow(dead_code)]
45pub enum SidebarVariant {
46 #[default]
48 Sidebar,
49 Floating,
51 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
67#[allow(dead_code)]
68pub enum SidebarCollapsible {
69 #[default]
71 Offcanvas,
72 Icon,
74 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#[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
115const 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#[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#[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#[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#[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#[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#[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#[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#[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#[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
391const 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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}