{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"motion-sidebar","type":"registry:component","title":"Sidebar (Motion)","description":"Sidebar with motion: it springs its width as it folds, the current page's highlight and the hover glide from row to row, and sections open on a gliding height.","dependencies":["lucide-react","motion"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/motion-hover.json","https://dashral.com/r/motion-reduced.json","https://dashral.com/r/motion-tokens.json","https://dashral.com/r/sidebar.json","https://dashral.com/r/motion-dropdown-menu.json","https://dashral.com/r/motion-tooltip.json"],"files":[{"path":"registry/dashral/motion/sidebar.tsx","type":"registry:file","target":"components/dashral/motion/sidebar.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronRightIcon, PanelLeftCloseIcon, PanelLeftOpenIcon } from \"lucide-react\"\nimport { AnimatePresence, LayoutGroup, motion, type MotionValue } from \"motion/react\"\nimport type { SidebarLink, SidebarProps } from \"../sidebar\"\nimport { useLocale } from \"../lib/locale\"\nimport { cn } from \"../lib/utils\"\n\nimport { useHoverPill } from \"../lib/motion-hover\"\nimport { useReducedMotion } from \"../lib/motion-reduced\"\nimport { duration, ease, spring } from \"../lib/motion-tokens\"\nimport { DropdownMenu } from \"./dropdown-menu\"\nimport { Tooltip } from \"./tooltip\"\n\nexport type { SidebarProps, SidebarItem, SidebarLink } from \"../sidebar\"\n\n// The list's columns are minmax(0, 1fr) (grid-cols-1): a row can shrink below its label's width as the sidebar narrows.\n// 14px in from the row's edge, the icon sits in the middle of the narrowed sidebar (60px), so it never moves as it folds.\nconst rowClass =\n  \"relative flex w-full items-center gap-3 rounded-md px-3.5 py-2 text-[13px] font-medium text-sidebar-foreground outline-none transition-colors hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-[current=page]:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0\"\n\n/** What every row shares: the collapse, the hover and the motion settings. */\nconst RowContext = React.createContext<{\n  collapsed: boolean\n  reduced: boolean\n  side: \"left\" | \"right\"\n  linkAs: React.ElementType\n  hot: string | null\n  enter: (key: string) => void\n  opacity: MotionValue<number> | null\n  className?: string\n}>({ collapsed: false, reduced: false, side: \"right\", linkAs: \"a\", hot: null, enter: () => {}, opacity: null })\n\n/**\n * The kit's Sidebar with motion: the same items, header, footer, collapse and props. Collapsing springs its width and\n * the labels fade as they slide away; the current page's highlight is one element gliding from row to row; the hover\n * is one soft pill gliding after the pointer without a blink; a section opens with its height gliding and its chevron\n * turning, its links rising in. Collapsed, rows name themselves in tooltips and a section opens its links in a menu\n * beside it. With reduced motion everything changes at once.\n */\nexport function Sidebar({\n  items,\n  header,\n  footer,\n  collapsed: collapsedProp,\n  defaultCollapsed = false,\n  onCollapsedChange,\n  collapsible = true,\n  linkAs = \"a\",\n  \"aria-label\": ariaLabel,\n  className,\n  classNames,\n  ...props\n}: SidebarProps) {\n  const { t, dir } = useLocale()\n  const reduced = useReducedMotion()\n  const [own, setOwn] = React.useState(defaultCollapsed)\n  const collapsed = collapsedProp ?? own\n  const toggle = () => {\n    if (collapsedProp === undefined) setOwn(!collapsed)\n    onCollapsedChange?.(!collapsed)\n  }\n  const side = dir === \"rtl\" ? \"left\" : \"right\"\n  const Toggle = collapsed !== (dir === \"rtl\") ? PanelLeftOpenIcon : PanelLeftCloseIcon\n  const hover = useHoverPill<string>(reduced)\n  const group = React.useId()\n\n  const context = { collapsed, reduced, side, linkAs, hot: hover.hot, enter: hover.enter, opacity: hover.opacity, className: classNames?.item } as const\n\n  return (\n    <motion.aside\n      data-slot=\"sidebar\"\n      data-collapsed={collapsed || undefined}\n      initial={false}\n      animate={{ width: collapsed ? 60 : 240 }}\n      transition={reduced ? { duration: 0 } : spring.glide}\n      className={cn(\"flex h-full shrink-0 flex-col overflow-hidden border-e border-sidebar-border bg-sidebar text-sidebar-foreground\", className)}\n      {...(props as React.ComponentProps<typeof motion.aside>)}\n    >\n      {header && (\n        // Narrowed, only its first 28px show (a logo's mark), right where the icons below sit; the rest is clipped away.\n        <div className={cn(\"flex h-14 shrink-0 items-center ps-4 pe-3\", classNames?.header)}>\n          <motion.div\n            className=\"flex min-w-0 items-center overflow-hidden\"\n            initial={false}\n            animate={{ maxWidth: collapsed ? 28 : 208 }}\n            transition={reduced ? { duration: 0 } : spring.glide}\n          >\n            {header}\n          </motion.div>\n        </div>\n      )}\n      <nav aria-label={ariaLabel ?? t(\"navigation\")} className={cn(\"min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-2 py-2\", classNames?.nav)}>\n        <RowContext value={context}>\n          <LayoutGroup id={group}>\n            {/* The list, not each row, is the stacking context: the highlights stay behind every row's text. */}\n            <div className=\"relative isolate grid grid-cols-1 gap-0.5\" onPointerLeave={hover.leave}>\n              {items.map((item, i) =>\n                item.type === \"separator\" ? (\n                  <hr key={i} className=\"my-2 border-sidebar-border\" />\n                ) : item.type === \"group\" ? (\n                  <div key={i} role=\"group\" aria-label={typeof item.label === \"string\" ? item.label : undefined} className=\"mt-3 grid grid-cols-1 gap-0.5 first:mt-0\">\n                    {item.label && (\n                      <div className=\"relative h-6\">\n                        <AnimatePresence initial={false}>\n                          {collapsed ? (\n                            <motion.hr\n                              key=\"line\"\n                              className=\"absolute inset-x-2 top-1/2 border-sidebar-border\"\n                              initial={{ opacity: 0 }}\n                              animate={{ opacity: 1 }}\n                              exit={{ opacity: 0 }}\n                              transition={{ duration: duration.fast }}\n                            />\n                          ) : (\n                            <motion.p\n                              key=\"label\"\n                              className=\"absolute inset-x-0 truncate px-3 pt-1 text-[11px] font-medium tracking-wide whitespace-nowrap text-muted-foreground uppercase\"\n                              initial={reduced ? { opacity: 0 } : { opacity: 0, x: -6 }}\n                              animate={{ opacity: 1, x: 0 }}\n                              exit={{ opacity: 0, transition: { duration: duration.fast } }}\n                              transition={{ duration: duration.standard, ease: ease.enter, delay: reduced ? 0 : 0.08 }}\n                            >\n                              {item.label}\n                            </motion.p>\n                          )}\n                        </AnimatePresence>\n                      </div>\n                    )}\n                    {item.items.map((link, j) => (\n                      <Row key={j} id={`${i}-${j}`} link={link} />\n                    ))}\n                  </div>\n                ) : (\n                  <Row key={i} id={String(i)} link={item} />\n                )\n              )}\n            </div>\n          </LayoutGroup>\n        </RowContext>\n      </nav>\n      {(footer || collapsible) && (\n        <div className={cn(\"grid shrink-0 grid-cols-1 gap-2 border-t border-sidebar-border p-2\", classNames?.footer)}>\n          {/* Clipped to the row's width, so folded only what's in line with the icons shows (an avatar, say). */}\n          {footer && <div className=\"min-w-0 overflow-hidden\">{footer}</div>}\n          {collapsible && (\n            <Tooltip content={collapsed ? t(\"expandSidebar\") : t(\"collapseSidebar\")} side={side}>\n              <button\n                type=\"button\"\n                onClick={toggle}\n                aria-label={collapsed ? t(\"expandSidebar\") : t(\"collapseSidebar\")}\n                aria-expanded={!collapsed}\n                className={cn(rowClass, \"text-muted-foreground hover:bg-sidebar-accent/60\")}\n              >\n                <Toggle aria-hidden />\n                <Label collapsed={collapsed} reduced={reduced}>\n                  {collapsed ? t(\"expandSidebar\") : t(\"collapseSidebar\")}\n                </Label>\n              </button>\n            </Tooltip>\n          )}\n        </div>\n      )}\n    </motion.aside>\n  )\n}\n\n/** A row's words: they fade as they slide away while the sidebar narrows, and come back after it's widened. */\nfunction Label({ collapsed, reduced, children }: { collapsed: boolean; reduced: boolean; children: React.ReactNode }) {\n  return (\n    <motion.span\n      // Still read out while it's faded: the narrowed sidebar only hides it from sight.\n      className=\"min-w-0 flex-1 truncate text-start whitespace-nowrap\"\n      initial={false}\n      animate={collapsed ? { opacity: 0, x: -8 } : { opacity: 1, x: 0 }}\n      transition={reduced ? { duration: 0 } : { duration: collapsed ? duration.fast : duration.standard, ease: collapsed ? ease.exit : ease.enter, delay: collapsed ? 0 : 0.1 }}\n    >\n      {children}\n    </motion.span>\n  )\n}\n\n/** The two highlights a row can have under it: the current page's (one element gliding between rows) and the hover's. */\nfunction Highlights({ id, current }: { id: string; current: boolean }) {\n  const { hot, opacity, reduced } = React.useContext(RowContext)\n  return (\n    <>\n      {current && <motion.span layoutId=\"sidebar-current\" aria-hidden className=\"absolute inset-0 -z-10 rounded-md bg-sidebar-accent\" transition={reduced ? { duration: 0 } : spring.move} />}\n      {hot === id && opacity && (\n        <motion.span\n          layoutId=\"sidebar-hover\"\n          aria-hidden\n          className=\"absolute inset-0 -z-20 rounded-md bg-sidebar-accent/60\"\n          style={{ opacity }}\n          transition={reduced ? { duration: 0 } : spring.move}\n        />\n      )}\n    </>\n  )\n}\n\nfunction Row({ id, link }: { id: string; link: SidebarLink }) {\n  const { collapsed, reduced, side, linkAs: Link, enter, className } = React.useContext(RowContext)\n  const childActive = link.children?.some((child) => child.active) ?? false\n  const [open, setOpen] = React.useState(link.defaultOpen ?? childActive)\n  // A section with the current page in it opens when you arrive there.\n  const [wasActive, setWasActive] = React.useState(childActive)\n  if (wasActive !== childActive) {\n    setWasActive(childActive)\n    if (childActive) setOpen(true)\n  }\n  const sectionId = React.useId()\n  const hovered = { onPointerEnter: () => !link.disabled && enter(id) }\n\n  const inner = (\n    <>\n      {link.icon}\n      <Label collapsed={collapsed} reduced={reduced}>\n        {link.label}\n      </Label>\n      {link.badge && !collapsed && <span className=\"shrink-0\">{link.badge}</span>}\n    </>\n  )\n\n  if (link.children) {\n    if (collapsed) {\n      // Narrowed: the section's links open in a menu beside it.\n      return (\n        <DropdownMenu\n          side={side}\n          align=\"start\"\n          className=\"min-w-44\"\n          linkAs={Link}\n          items={[\n            { type: \"label\", label: link.label },\n            ...link.children.map((child) => ({ label: child.label, icon: child.icon, href: child.href, active: child.active, disabled: child.disabled, onSelect: child.onSelect })),\n          ]}\n          trigger={\n            <button type=\"button\" aria-label={link.label} disabled={link.disabled} {...hovered} className={cn(rowClass, className)}>\n              <Highlights id={id} current={childActive} />\n              {link.icon}\n            </button>\n          }\n        />\n      )\n    }\n    return (\n      <div>\n        <button type=\"button\" onClick={() => setOpen(!open)} aria-expanded={open} aria-controls={sectionId} disabled={link.disabled} {...hovered} className={cn(rowClass, className)}>\n          {/* Closed with the current page inside, the section's own row carries the highlight. */}\n          <Highlights id={id} current={childActive && !open} />\n          {inner}\n          <motion.span className=\"flex\" animate={{ rotate: open ? 90 : 0 }} transition={reduced ? { duration: 0 } : spring.move}>\n            <ChevronRightIcon aria-hidden className=\"rtl:-scale-x-100\" />\n          </motion.span>\n        </button>\n        <AnimatePresence initial={false}>\n          {open && (\n            <motion.div\n              id={sectionId}\n              key=\"section\"\n              className=\"overflow-hidden\"\n              initial={{ height: 0, opacity: 0 }}\n              animate={{ height: \"auto\", opacity: 1 }}\n              exit={{ height: 0, opacity: 0 }}\n              transition={reduced ? { duration: 0 } : { ...spring.glide, opacity: { duration: duration.fast } }}\n            >\n              <div className=\"ms-5 grid grid-cols-1 gap-0.5 border-s border-sidebar-border py-0.5 ps-2\">\n                {link.children.map((child, i) => (\n                  <motion.div\n                    key={i}\n                    initial={reduced ? false : { opacity: 0, x: -6 }}\n                    animate={{ opacity: 1, x: 0 }}\n                    transition={{ duration: duration.standard, ease: ease.enter, delay: 0.04 + i * 0.03 }}\n                  >\n                    <Row id={`${id}-${i}`} link={child} />\n                  </motion.div>\n                ))}\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    )\n  }\n\n  const row = link.href ? (\n    <Link\n      href={link.href}\n      aria-current={link.active ? \"page\" : undefined}\n      aria-disabled={link.disabled || undefined}\n      onClick={link.onSelect}\n      {...hovered}\n      className={cn(rowClass, link.disabled && \"pointer-events-none opacity-50\", className)}\n    >\n      <Highlights id={id} current={!!link.active} />\n      {inner}\n    </Link>\n  ) : (\n    <button type=\"button\" onClick={link.onSelect} disabled={link.disabled} aria-current={link.active ? \"page\" : undefined} {...hovered} className={cn(rowClass, className)}>\n      <Highlights id={id} current={!!link.active} />\n      {inner}\n    </button>\n  )\n\n  // Narrowed to its icon, a row says its name in a tooltip beside it.\n  return collapsed ? (\n    <Tooltip content={link.label} side={side}>\n      {row}\n    </Tooltip>\n  ) : (\n    row\n  )\n}\n"}]}