{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "sidebar-tabs", "type": "registry:ui", "title": "SidebarTabs", "description": "TabBar's wide-layout counterpart: a vertical tab rail for a desktop sidebar, with each item's progress pinned to its bottom edge.", "categories": [ "navigation" ], "registryDependencies": [ "https://whiskeyjack.net/r/merge-refs.json", "https://whiskeyjack.net/r/progress-bar.json", "https://whiskeyjack.net/r/tab-bar.json", "https://whiskeyjack.net/r/utils.json" ], "files": [ { "path": "components/ui/sidebar-tabs.tsx", "type": "registry:ui", "target": "components/ui/sidebar-tabs.tsx", "content": "import * as React from 'react'\nimport { cn } from '@/lib/utils'\nimport { useMergedRefs } from '@/lib/merge-refs'\nimport { ProgressBar } from '@/components/ui/progress-bar'\nimport type { TabBarItem } from '@/components/ui/tab-bar'\n\nexport interface SidebarTabsProps {\n items: TabBarItem[]\n activeId: string | null\n onSelect: (id: string) => void\n 'aria-label': string\n className?: string\n}\n\n/**\n * Vertical counterpart to the DS TabBar -- the wide-desktop (xl+) presentation\n * of the same tab set, rendered into a Layout's left sidebar. The horizontal\n * TabBar covers the narrower phases; this is shown only once the sidebar\n * appears (the page hides one or the other via CSS, never both at once). Takes\n * the same TabBarItem[] so it is a drop-in: the active item gets the nav-style\n * highlight, its optional icon sits before the label, and its progress shows as\n * a thin bar pinned to the bottom edge -- the same indicator as the mobile\n * underline, kept off the label so the accent never hurts text contrast (see\n * ProgressFill).\n */\nexport const SidebarTabs = React.forwardRef(function SidebarTabs(\n { items, activeId, onSelect, 'aria-label': ariaLabel, className },\n ref,\n) {\n const listRef = React.useRef(null)\n const mergedRef = useMergedRefs(listRef, ref)\n\n // Vertical roving-tabindex keyboard model (the horizontal TabBar's Left/Right,\n // turned 90 degrees): Up/Down move between tabs, Home/End jump to the ends.\n const handleKeyDown = (e: React.KeyboardEvent) => {\n if (!items.length) return\n const current = activeId ? items.findIndex((t) => t.id === activeId) : -1\n let next: number | null = null\n if (e.key === 'ArrowDown') next = current < items.length - 1 ? current + 1 : 0\n else if (e.key === 'ArrowUp') next = current > 0 ? current - 1 : items.length - 1\n else if (e.key === 'Home') next = 0\n else if (e.key === 'End') next = items.length - 1\n if (next !== null) {\n e.preventDefault()\n const item = items[next]\n onSelect(item.id)\n listRef.current?.querySelector(`[data-tab-id=\"${item.id}\"]`)?.focus()\n }\n }\n\n return (\n \n {items.map((item) => {\n const isActive = item.id === activeId\n return (\n onSelect(item.id)}\n className={cn(\n // Regular rounded highlight, like the header nav items. The\n // progress fill sits as a layer between the highlight and the text;\n // overflow-hidden clips it to the rounded corners (the focus ring\n // is an outline, so it is not clipped).\n 'wj-focus-ring relative flex w-full items-center gap-2 overflow-hidden rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors',\n isActive\n ? 'bg-[var(--color-warm-100)] text-[var(--color-warm-700)] dark:bg-[var(--color-neutral-800)] dark:text-white'\n : 'text-[var(--color-text-secondary-light)] hover:bg-[var(--color-warm-50)] dark:text-[var(--color-text-secondary-dark)] dark:hover:bg-[var(--color-neutral-800)]'\n )}\n >\n {isActive && (\n \n )}\n {item.icon && (\n \n {item.icon}\n \n )}\n {item.label}\n \n )\n })}\n \n )\n})\n\nSidebarTabs.displayName = 'SidebarTabs'\n\n/**\n * The active item's indicator -- a thin bar pinned to the bottom of the item,\n * mirroring the mobile/tablet TabBar underline, over the item's highlight\n * background. Kept at the bottom (rather than filling the item) so the accent\n * never sits behind the label, which fails contrast in light mode. The button's\n * rounded corners clip the bar's bottom ends -- fine for the continuous bars,\n * but the segment strip is inset to the label's padding instead: the corner\n * curve would swallow the first (whole) dot, and discrete dots read as broken\n * when clipped.\n * - underline 'shimmer' (special / occasional tabs): a dimmed accent line with a\n * slow full-accent sweep (static under reduced motion);\n * - 'indeterminate' (free / count streaks): the DS segment-accumulation\n * indicator -- solid dots on a faint dotted track, every 10 collapsing into a\n * pill -- driven by the item's `count` (plain dotted line without one);\n * - a 0..1 number: a full-accent fill spanning that fraction over a faint-accent\n * (low opacity) track, matching the mobile/tablet TabBar underline;\n * - no progress concept (plain sections): nothing -- the highlight alone marks\n * the active item.\n */\nfunction ProgressFill({\n progress,\n count,\n underline,\n}: {\n progress?: number | 'indeterminate'\n count?: number\n underline?: 'wave' | 'shimmer'\n}) {\n if (underline === 'shimmer') {\n return (\n \n \n \n \n )\n }\n if (progress == null) return null\n if (progress === 'indeterminate') {\n return (\n // inset-x-2 equals the button's rounded-lg corner radius (8px), so the\n // strip spans the full straight run of the bottom edge -- the first dot\n // starts exactly where the corner curve ends, without hugging the label\n // padding (12px read as too much clearance).\n \n \n \n )\n }\n const pct = Math.round(Math.max(0, Math.min(1, progress)) * 100)\n return (\n \n {/* Faint-accent track for the unfilled part + full-accent fill on top --\n the same treatment as the DS ProgressBar / mobile TabBar underline (the\n button's rounded corners clip the bar's ends). */}\n \n \n \n )\n}\n" } ], "docs": "Feed SidebarTabs the same items, activeId, and onSelect as your TabBar, then show one at a time: portal this into the sidebar for xl+ layouts while hiding the horizontal TabBar with lg:hidden. Arrow keys, Home, and End move between items.", "meta": { "group": "navigation", "related": [ "tab-bar", "progress-bar" ], "exports": [ "SidebarTabs" ], "siteSlug": "sidebar-tabs" } }