{
  "$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/use-nav-reset.json",
    "https://whiskeyjack.net/r/use-reduced-motion.json",
    "https://whiskeyjack.net/r/use-sliding-indicator.json",
    "https://whiskeyjack.net/r/use-stand-in-target.json",
    "https://whiskeyjack.net/r/use-travel-direction.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 { useReducedMotion } from '@/hooks/use-reduced-motion'\nimport { useSlidingIndicator } from '@/hooks/use-sliding-indicator'\nimport { useTravelDirection } from '@/hooks/use-travel-direction'\nimport { useMergedRefs } from '@/lib/merge-refs'\nimport { useRegisterNavReveal } from '@/hooks/use-nav-reset'\nimport { nearestScroller } from '@/hooks/use-stand-in-target'\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  /** Inline styles for the root element. */\n  style?: React.CSSProperties\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<HTMLDivElement, SidebarTabsProps>(function SidebarTabs(\n  { items, activeId, onSelect, 'aria-label': ariaLabel, className, style },\n  ref,\n) {\n  const listRef = React.useRef<HTMLDivElement>(null)\n  const mergedRef = useMergedRefs(listRef, ref)\n  const reduced = useReducedMotion()\n  // The highlight travels down the rail instead of appearing on the new item,\n  // the vertical read of the TabBar underline sliding along the strip.\n  const indicator = useSlidingIndicator(listRef, activeId, {\n    axis: 'y',\n    changeKey: items.map((i) => i.id).join('\\n'),\n  })\n  // Which edge leads depends on the direction of travel, so `.wj-nav-highlight`\n  // names it -- the rail's read of the SegmentedControl pill's stretch.\n  const direction = useTravelDirection(items.findIndex((i) => i.id === activeId))\n  const activeItem = activeId ? items.find((i) => i.id === activeId) : undefined\n\n  // The nav's double-tap asks every strip to bring its active tab back into\n  // view. The rail scrolls inside its sidebar pane, so the pane is what the\n  // item has to be inside of; declines when it already is, or when nothing\n  // around the rail scrolls, so the gesture can move on to the page's reset.\n  useRegisterNavReveal(() => {\n    const list = listRef.current\n    if (!list || !activeId) return false\n    const activeBtn = list.querySelector<HTMLElement>(`[data-tab-id=\"${activeId}\"]`)\n    if (!activeBtn) return false\n    const pane = nearestScroller(list)\n    if (!pane || pane.clientHeight === 0) return false\n    const p = pane.getBoundingClientRect()\n    const b = activeBtn.getBoundingClientRect()\n    if (b.top >= p.top && b.bottom <= p.bottom) return false\n    activeBtn.scrollIntoView({ block: 'center', behavior: reduced ? 'auto' : 'smooth' })\n    return true\n  })\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<HTMLDivElement>) => {\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<HTMLElement>(`[data-tab-id=\"${item.id}\"]`)?.focus()\n    }\n  }\n\n  return (\n    <div\n      ref={mergedRef}\n      role=\"tablist\"\n      aria-orientation=\"vertical\"\n      aria-label={ariaLabel}\n      onKeyDown={handleKeyDown}\n      className={cn('relative flex flex-col gap-1', className)}\n      style={style}\n    >\n      {indicator && (\n        <span\n          aria-hidden\n          data-dir={direction}\n          className={cn(\n            // overflow-hidden so the progress bar's ends are clipped by the\n            // same rounded corners the highlight has.\n            'pointer-events-none absolute inset-x-0 z-0 overflow-hidden rounded-lg',\n            'bg-[var(--wj-nav-active)] shadow-bevel',\n            // Positioned by both edges, so `.wj-nav-highlight` can give each its\n            // own easing and the highlight stretches through the travel.\n            !reduced && 'wj-nav-highlight',\n          )}\n          style={{\n            top: indicator.offset,\n            bottom: indicator.extent - (indicator.offset + indicator.size),\n          }}\n        >\n          {/* The progress bar rides INSIDE the travelling highlight rather than\n              inside the item it belongs to. Rendered per item, it appeared on\n              the destination the instant the selection changed, arriving ahead\n              of the highlight still crossing the rail. */}\n          {activeItem && (\n            <ProgressFill\n              progress={activeItem.progress}\n              count={activeItem.count}\n              underline={activeItem.underline}\n            />\n          )}\n        </span>\n      )}\n      {items.map((item) => {\n        const isActive = item.id === activeId\n        return (\n          <button\n            key={item.id}\n            role=\"tab\"\n            aria-selected={isActive}\n            tabIndex={isActive ? 0 : -1}\n            data-tab-id={item.id}\n            onClick={() => 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              // z-10 so the label rides above the travelling highlight.\n              // wj-nav-handoff fades this item's own fill out as the\n              // highlight crosses the rail to reach it; it only takes effect\n              // on the selected item, so it costs the others nothing.\n              'wj-focus-ring wj-nav-handoff relative z-10 flex w-full min-h-control-md items-center gap-2 overflow-hidden rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors',\n              isActive\n                ? 'text-[var(--color-text-primary-light)] dark:text-[var(--color-text-primary-dark)]'\n                : 'text-[var(--color-text-secondary-light)] hover:bg-[var(--wj-nav-active)] dark:text-[var(--color-text-secondary-dark)]'\n            )}\n          >\n            {item.icon && (\n              <span aria-hidden className=\"relative flex shrink-0 items-center\">\n                {item.icon}\n              </span>\n            )}\n            <span className=\"relative block leading-snug\">{item.label}</span>\n            {/* Trailing, against the rail's edge, where a column of figures\n                lines up. The strip renders it after the label instead, which is\n                the only presentational difference between the two. */}\n            {item.tally !== undefined && (\n              <span className=\"relative ml-auto shrink-0 tabular-nums opacity-60\">{item.tally}</span>\n            )}\n          </button>\n        )\n      })}\n    </div>\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      <span aria-hidden className=\"absolute inset-x-0 bottom-0 h-[3px]\">\n        <span className=\"absolute inset-0 bg-[var(--color-accent-500)] motion-safe:opacity-50\" />\n        <span className=\"wj-tab-shimmer absolute inset-0\" />\n      </span>\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      <span aria-hidden className=\"absolute inset-x-2 bottom-0 h-[3px]\">\n        <ProgressBar indeterminate count={count} size=\"xs\" decorative className=\"h-full\" />\n      </span>\n    )\n  }\n  const pct = Math.round(Math.max(0, Math.min(1, progress)) * 100)\n  return (\n    <span aria-hidden className=\"absolute inset-x-0 bottom-0 h-[3px]\">\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      <span\n        className=\"absolute inset-0\"\n        style={{ background: 'color-mix(in srgb, var(--color-accent-500) 35%, transparent)' }}\n      />\n      <span\n        className=\"absolute inset-y-0 start-0 bg-[var(--color-accent-500)] motion-safe:transition-[width] duration-500 ease-out\"\n        style={{ width: `${pct}%` }}\n      />\n    </span>\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"
  }
}
