Sidebar

Basic

// Package sidebar holds the site's example gsx components for ui/sidebar.
package sidebar

import (
	"github.com/gsxhq/gsxui/ui"
	"github.com/gsxhq/gsxui/ui/icon"
)

// Basic mirrors shadcn's own sidebar-demo.tsx shape: a header, a grouped
// menu (SidebarGroup > SidebarGroupLabel + SidebarMenu), a footer, a
// trigger, and SidebarInset holding the "page" content next to it.
component Basic() {
	<ui.SidebarProvider open={true} class="min-h-[32rem] rounded-lg border">
		<ui.Sidebar open={true}>
			<ui.SidebarHeader>
				<div class="px-2 py-1 text-sm font-semibold">Acme Inc</div>
				<ui.SidebarInput placeholder="Search navigation"/>
			</ui.SidebarHeader>
			<ui.SidebarSeparator/>
			<ui.SidebarContent>
				<ui.SidebarGroup>
					<ui.SidebarGroupLabel>Application</ui.SidebarGroupLabel>
					<ui.SidebarGroupAction aria-label="Add item">
						<icon.Plus/>
					</ui.SidebarGroupAction>
					<ui.SidebarGroupContent>
						<ui.SidebarMenu>
							<ui.SidebarMenuItem>
								<ui.SidebarMenuButton href="#home" isActive={true} tooltip="Home">
									<icon.House/>
									<span>Home</span>
								</ui.SidebarMenuButton>
							</ui.SidebarMenuItem>
							<ui.SidebarMenuItem>
								<ui.SidebarMenuButton variant="outline" size="sm">
									<icon.Inbox/>
									<span>Inbox</span>
								</ui.SidebarMenuButton>
								<ui.SidebarMenuAction showOnHover={true} aria-label="More">
									<icon.Plus/>
								</ui.SidebarMenuAction>
								<ui.SidebarMenuSub>
									<ui.SidebarMenuSubItem>
										<ui.SidebarMenuSubButton size="sm" isActive={true}>Unread</ui.SidebarMenuSubButton>
									</ui.SidebarMenuSubItem>
									<ui.SidebarMenuSubItem>
										<ui.SidebarMenuSubButton size="md">Archived</ui.SidebarMenuSubButton>
									</ui.SidebarMenuSubItem>
								</ui.SidebarMenuSub>
							</ui.SidebarMenuItem>
							<ui.SidebarMenuItem>
								<ui.SidebarMenuButton size="lg">
									<icon.Calendar/>
									<span>Calendar</span>
								</ui.SidebarMenuButton>
								<ui.SidebarMenuBadge>24</ui.SidebarMenuBadge>
							</ui.SidebarMenuItem>
							<ui.SidebarMenuItem>
								<ui.SidebarMenuButton>
									<icon.Search/>
									<span>Search</span>
								</ui.SidebarMenuButton>
								<ui.SidebarMenuAction aria-label="Pin">
									<icon.Plus/>
								</ui.SidebarMenuAction>
							</ui.SidebarMenuItem>
						</ui.SidebarMenu>
						<ui.SidebarMenuSkeleton showIcon={true}/>
						<ui.SidebarMenuSkeleton/>
					</ui.SidebarGroupContent>
				</ui.SidebarGroup>
			</ui.SidebarContent>
			<ui.SidebarFooter>
				<ui.SidebarMenu>
					<ui.SidebarMenuItem>
						<ui.SidebarMenuButton tooltip="Settings">
							<icon.Settings/>
							<span>Settings</span>
						</ui.SidebarMenuButton>
					</ui.SidebarMenuItem>
				</ui.SidebarMenu>
			</ui.SidebarFooter>
			<ui.SidebarRail/>
		</ui.Sidebar>
		<ui.SidebarInset>
			<header class="flex h-12 items-center gap-2 border-b px-4">
				<ui.SidebarTrigger/>
				<span class="text-sm text-muted-foreground">Dashboard</span>
			</header>
			<div class="p-4 text-sm text-muted-foreground">
				Toggle the sidebar with the button above, the rail at its edge, or Cmd/Ctrl+B.
			</div>
		</ui.SidebarInset>
	</ui.SidebarProvider>
}

Variants

variant=sidebar (default)
variant=floating
variant=inset
collapsible=offcanvas (default)
collapsible=icon
collapsible=none (always expanded, no rail)
right side, collapsed
icon collapsed
package sidebar

import (
	"github.com/gsxhq/gsxui/ui"
	"github.com/gsxhq/gsxui/ui/icon"
)

// brand uses the same overflow behavior as the rest of an icon-collapsible
// menu: the fixed A mark remains visible while the full company name is
// clipped by SidebarMenuButton's compact state.
component brand() {
	<ui.SidebarMenu>
		<ui.SidebarMenuItem>
			<ui.SidebarMenuButton
				size="lg"
				tooltip="Acme Inc"
				aria-label="Acme Inc"
				data-sidebar-example-brand
			>
				<span
					class="flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
					data-sidebar-example-brand-mark
				>
					A
				</span>
				<span class="truncate font-semibold" data-sidebar-example-brand-name>Acme Inc</span>
			</ui.SidebarMenuButton>
		</ui.SidebarMenuItem>
	</ui.SidebarMenu>
}

// menu is the shared three-item menu every mini demo below reuses, so the
// only thing that visibly differs between them is the variant/collapsible
// axis each one is labeled for.
component menu() {
	<ui.SidebarMenu>
		<ui.SidebarMenuItem>
			<ui.SidebarMenuButton isActive={true} tooltip="Home">
				<icon.House/>
				<span>Home</span>
			</ui.SidebarMenuButton>
		</ui.SidebarMenuItem>
		<ui.SidebarMenuItem>
			<ui.SidebarMenuButton tooltip="Inbox">
				<icon.Inbox/>
				<span>Inbox</span>
			</ui.SidebarMenuButton>
		</ui.SidebarMenuItem>
		<ui.SidebarMenuItem>
			<ui.SidebarMenuButton tooltip="Settings">
				<icon.Settings/>
				<span>Settings</span>
			</ui.SidebarMenuButton>
		</ui.SidebarMenuItem>
	</ui.SidebarMenu>
}

// demo renders one SidebarProvider. The gallery runs every exported case
// below in its own iframe because each desktop Sidebar intentionally owns
// its document viewport; mounting several cases in one document would make
// their fixed containers overlap.
// SidebarRail is only meaningful alongside offcanvas/icon collapsing (it
// positions absolute against the desktop tree's own fixed sidebar-
// container, which collapsible="none"'s flat div never renders — see
// ui/sidebar.gsx's own Sidebar doc comment), so rail gates it off for that
// one case.
component demo(side string, variant string, collapsible string, open bool, rail bool) {
	<ui.SidebarProvider open={open}>
		<ui.Sidebar open={open} side={side} variant={variant} collapsible={collapsible}>
			<ui.SidebarHeader>
				<brand/>
			</ui.SidebarHeader>
			<ui.SidebarContent>
				<ui.SidebarGroup>
					<menu/>
				</ui.SidebarGroup>
			</ui.SidebarContent>
			{ if rail {
				<ui.SidebarRail/>
			} }
		</ui.Sidebar>
		<ui.SidebarInset>
			<header class="flex h-12 items-center gap-2 border-b px-4">
				<ui.SidebarTrigger/>
			</header>
		</ui.SidebarInset>
	</ui.SidebarProvider>
}

// Variants demonstrates the three `variant`s (sidebar/floating/inset) and
// the three `collapsible` modes (offcanvas/icon/none) shadcn's own registry
// exposes as separate props on the same component. It is the default case;
// the gallery registers the sibling cases below as named preview documents
// under this same source block.
component Variants() {
	<demo side="" variant="" collapsible="" open={true} rail={true}/>
}

component VariantFloating() {
	<demo side="" variant="floating" collapsible="" open={true} rail={true}/>
}

component VariantInset() {
	<demo side="" variant="inset" collapsible="" open={true} rail={true}/>
}

component CollapsibleOffcanvas() {
	<demo side="" variant="" collapsible="offcanvas" open={true} rail={true}/>
}

component CollapsibleIcon() {
	<demo side="" variant="" collapsible="icon" open={true} rail={true}/>
}

component CollapsibleNone() {
	<demo side="" variant="" collapsible="none" open={true} rail={false}/>
}

component RightCollapsed() {
	<demo side="right" variant="" collapsible="offcanvas" open={false} rail={true}/>
}

component IconCollapsed() {
	<demo side="" variant="" collapsible="icon" open={false} rail={true}/>
}

Persisted (cookie round-trip)

package sidebar

import (
	"github.com/gsxhq/gsxui/ui"
	"github.com/gsxhq/gsxui/ui/icon"
)

// Persisted is the copyable recipe for the one thing ui/sidebar.js
// deliberately does NOT do: remember open/collapsed across page loads.
// shadcn's own React SidebarProvider writes a non-HttpOnly `sidebar_state`
// cookie on every toggle and reads it back via a Server Component; gsxui
// drops that from the component itself (ui/sidebar.gsx's own package doc
// comment, decision 1) because a component that unilaterally picks a
// storage mechanism fights every consumer who'd rather use a Go session,
// Alpine, htmx, or nothing at all. This example is the two halves a real
// app wires up instead:
//
//  1. SERVER: read the `sidebar_state` cookie into `open` before you
//     render SidebarProvider — a plain net/http example (swap in your own
//     router's request/cookie types, the shape is identical):
//
//     func Page(w http.ResponseWriter, r *http.Request) {
//         open := true // no cookie yet: shadcn's own default is expanded
//         if c, err := r.Cookie("sidebar_state"); err == nil {
//             open = c.Value == "true"
//         }
//         ui.SidebarProvider(open, pageBody(), nil).Render(r.Context(), w)
//     }
//
//  2. CLIENT: write the cookie back whenever the sidebar toggles — a
//     four-line listener for the gsxui:change event sidebar.js emits on
//     SidebarProvider's own wrapper (see the <script> below, which is
//     this exact snippet, live). The target guard is NOT optional: gsxui:
//     change is also emitted by ui/tabs.js, ui/toggle.js, ui/toggle-
//     group.js and ui/resizable.js (review round 1, IMPORTANT 2) — without
//     it, switching a tab or flipping a toggle ANYWHERE on a page that
//     also has a sidebar would overwrite this cookie with
//     "sidebar_state=undefined":
//
//     document.addEventListener("gsxui:change", (e) => {
//       if (!e.target.matches?.('[data-gsxui-slot-sidebar-wrapper]')) return;
//       document.cookie = `sidebar_state=${e.detail.open}; path=/; max-age=604800`;
//     });
//
// 604800 is SIDEBAR_COOKIE_MAX_AGE from the shadcn source (7 days) — a
// plain number here, not a gsxui constant, since the component ships no
// cookie code at all to hang one off of. This demo can't show the actual
// reload-persists round trip (a static example gallery page, not a live
// per-visitor route), but toggling it below DOES write the real cookie in
// your browser — inspect it in devtools, or check `document.cookie` in the
// console.
component Persisted() {
	<div>
		<ui.SidebarProvider open={true}>
			<ui.Sidebar open={true} collapsible="icon">
				<ui.SidebarHeader>
					<brand/>
				</ui.SidebarHeader>
				<ui.SidebarContent>
					<ui.SidebarGroup>
						<ui.SidebarMenu>
							<ui.SidebarMenuItem>
								<ui.SidebarMenuButton isActive={true} tooltip="Home">
									<icon.House/>
									<span>Home</span>
								</ui.SidebarMenuButton>
							</ui.SidebarMenuItem>
							<ui.SidebarMenuItem>
								<ui.SidebarMenuButton tooltip="Settings">
									<icon.Settings/>
									<span>Settings</span>
								</ui.SidebarMenuButton>
							</ui.SidebarMenuItem>
						</ui.SidebarMenu>
					</ui.SidebarGroup>
				</ui.SidebarContent>
				<ui.SidebarRail/>
			</ui.Sidebar>
			<ui.SidebarInset>
				<header class="flex h-12 items-center gap-2 border-b px-4">
					<ui.SidebarTrigger/>
					<span class="text-sm text-muted-foreground">Toggle me, then check document.cookie</span>
				</header>
			</ui.SidebarInset>
		</ui.SidebarProvider>
		<script>
			document.addEventListener("gsxui:change", (e) => {
				if (!e.target.matches?.('[data-gsxui-slot-sidebar-wrapper]')) return;
				document.cookie = `sidebar_state=${e.detail.open}; path=/; max-age=604800`;
			});
		</script>
	</div>
}

RTL

package sidebar

import (
	"github.com/gsxhq/gsxui/ui"
	"github.com/gsxhq/gsxui/ui/icon"
)

// Rtl mirrors Basic's own shape (header, grouped menu, footer, trigger,
// inset content) but under dir="rtl" with side="right" — shadcn's own RTL
// convention keeps the physical side on the visual right so it still reads
// as inline-start in a right-to-left document. Everything else (icons,
// spacing, keyboard order) adapts through the same logical properties and
// rtl:rotate-180 rules Basic already exercises.
component Rtl() {
	<div dir="rtl">
		<ui.SidebarProvider open={true} class="min-h-[32rem] rounded-lg border">
			<ui.Sidebar open={true} side="right">
				<ui.SidebarHeader>
					<div class="px-2 py-1 text-sm font-semibold">شركة أكمي</div>
				</ui.SidebarHeader>
				<ui.SidebarSeparator/>
				<ui.SidebarContent>
					<ui.SidebarGroup>
						<ui.SidebarGroupLabel>التطبيق</ui.SidebarGroupLabel>
						<ui.SidebarGroupContent>
							<ui.SidebarMenu>
								<ui.SidebarMenuItem>
									<ui.SidebarMenuButton isActive={true} tooltip="الرئيسية">
										<icon.House/>
										<span>الرئيسية</span>
									</ui.SidebarMenuButton>
								</ui.SidebarMenuItem>
								<ui.SidebarMenuItem>
									<ui.SidebarMenuButton tooltip="البريد الوارد">
										<icon.Inbox/>
										<span>البريد الوارد</span>
									</ui.SidebarMenuButton>
								</ui.SidebarMenuItem>
								<ui.SidebarMenuItem>
									<ui.SidebarMenuButton tooltip="الإعدادات">
										<icon.Settings/>
										<span>الإعدادات</span>
									</ui.SidebarMenuButton>
								</ui.SidebarMenuItem>
							</ui.SidebarMenu>
						</ui.SidebarGroupContent>
					</ui.SidebarGroup>
				</ui.SidebarContent>
				<ui.SidebarRail/>
			</ui.Sidebar>
			<ui.SidebarInset>
				<header class="flex h-12 items-center gap-2 border-b px-4">
					<ui.SidebarTrigger/>
					<span class="text-sm text-muted-foreground">لوحة التحكم</span>
				</header>
				<div class="p-4 text-sm text-muted-foreground">
					بدّل الشريط الجانبي بالزر أعلاه، أو المقبض على حافته، أو Cmd/Ctrl+B.
				</div>
			</ui.SidebarInset>
		</ui.SidebarProvider>
	</div>
}