shadimage

sidebar

sidebar

src/components/ui/sidebar.tsx

"use client"

import { DialogCloseIcon } from "./dialog"

import { ChevronDownIcon } from "@heroicons/react/20/solid"
import { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { type ButtonProps, Button as PrimitiveButton } from "react-aria-components/Button"
import { composeRenderProps } from "react-aria-components/composeRenderProps"
import {
  type DisclosurePanelProps,
  type DisclosureProps,
  Disclosure,
  DisclosurePanel,
} from "react-aria-components/Disclosure"
import { DisclosureGroup, type DisclosureGroupProps } from "react-aria-components/DisclosureGroup"
import { Header } from "react-aria-components/Header"
import { Heading } from "react-aria-components/Heading"
import { type LinkProps, type LinkRenderProps } from "react-aria-components/Link"
import {
  type NavigationTreeItemContentProps,
  type NavigationTreeItemProps,
  type NavigationTreeProps,
  NavigationTree,
  NavigationTreeItem,
  NavigationTreeItemContent,
} from "react-aria-components/NavigationTree"
import { Separator, type SeparatorProps } from "react-aria-components/Separator"
import { cn } from "cn"
import { Sheet } from "@/components/ui/sheet"
import { TreeIndicator } from "@/components/ui/tree"
import { useIsMobile } from "@/hooks/use-mobile"
import { cx } from "@/lib/primitive"
import { Button } from "./button"
import { Link } from "./link"
import { TooltipTrigger, Tooltip } from "./tooltip"

const SIDEBAR_WIDTH = "17rem"
const SIDEBAR_WIDTH_DOCK = "3.25rem"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7

export type SidebarContextProps = {
  state: "expanded" | "collapsed"
  open: boolean
  setOpen: (open: boolean) => void
  isOpenOnMobile: boolean
  setIsOpenOnMobile: (open: boolean) => void
  isMobile: boolean
  toggleSidebar: () => void
}

const SidebarContext = createContext<SidebarContextProps | null>(null)

export function useSidebar() {
  const context = use(SidebarContext)
  if (!context) {
    throw new Error("useSidebar must be used within a SidebarProvider.")
  }

  return context
}

export interface SidebarProviderProps extends React.ComponentProps<"div"> {
  defaultOpen?: boolean
  isOpen?: boolean
  shortcut?: string
  onOpenChange?: (open: boolean) => void
}

export function SidebarProvider({
  defaultOpen = true,
  isOpen: openProp,
  onOpenChange: setOpenProp,
  className,
  style,
  children,
  shortcut = "b",
  ref,
  ...props
}: SidebarProviderProps) {
  const [openMobile, setOpenMobile] = useState(false)

  const [internalOpenState, setInternalOpenState] = useState(defaultOpen)
  const open = openProp ?? internalOpenState
  const setOpen = useCallback(
    (value: boolean | ((value: boolean) => boolean)) => {
      const openState = typeof value === "function" ? value(open) : value

      if (setOpenProp) {
        setOpenProp(openState)
      } else {
        setInternalOpenState(openState)
      }

      document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
    },
    [setOpenProp, open]
  )

  const isMobile = useIsMobile()
  const isMobileRef = useRef(isMobile)
  isMobileRef.current = isMobile

  const toggleSidebar = useCallback(() => {
    if (isMobileRef.current) {
      setOpenMobile((prev) => !prev)
    } else {
      setOpen((prev) => !prev)
    }
  }, [setOpen])

  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === shortcut && (event.metaKey || event.ctrlKey)) {
        const activeElement = document.activeElement

        const isInTextInput =
          activeElement instanceof HTMLInputElement ||
          activeElement instanceof HTMLTextAreaElement ||
          activeElement?.getAttribute("contenteditable") === "true" ||
          activeElement?.getAttribute("role") === "textbox"

        if (!isInTextInput) {
          event.preventDefault()
          toggleSidebar()
        }
      }
    }

    window.addEventListener("keydown", handleKeyDown)
    return () => window.removeEventListener("keydown", handleKeyDown)
  }, [toggleSidebar, shortcut])

  const state = isMobile || open ? "expanded" : "collapsed"

  const contextValue = useMemo<SidebarContextProps>(
    () => ({
      state,
      open,
      setOpen,
      isMobile: isMobile ?? false,
      isOpenOnMobile: openMobile,
      setIsOpenOnMobile: setOpenMobile,
      toggleSidebar,
    }),
    [state, open, setOpen, isMobile, openMobile, toggleSidebar]
  )

  if (isMobile === undefined) {
    return null
  }

  return (
    <SidebarContext value={contextValue}>
      <div
        style={
          {
            "--sidebar-width": SIDEBAR_WIDTH,
            "--sidebar-width-dock": SIDEBAR_WIDTH_DOCK,
            ...style,
          } as React.CSSProperties
        }
        className={cn(
          "@container **:[svg]:shrink-0",
          "flex w-full text-sidebar-foreground",
          "group/sidebar-root peer/sidebar-root has-data-[intent=inset]:bg-sidebar dark:has-data-[intent=inset]:bg-background",
          className
        )}
        ref={ref}
        {...props}
      >
        {children}
      </div>
    </SidebarContext>
  )
}

export interface SidebarProps extends React.ComponentProps<"div"> {
  intent?: "default" | "float" | "inset"
  collapsible?: "hidden" | "dock" | "none"
  side?: "left" | "right"
  closeButton?: boolean
}

export function Sidebar({
  children,
  closeButton = true,
  collapsible = "hidden",
  side = "left",
  intent = "default",
  className,
  ...props
}: SidebarProps) {
  const { isMobile, state, isOpenOnMobile, setIsOpenOnMobile } = useSidebar()
  if (collapsible === "none") {
    return (
      <div
        data-intent={intent}
        data-collapsible="none"
        data-slot="sidebar"
        className={cn(
          "flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
          className
        )}
        {...props}
      >
        {children}
      </div>
    )
  }

  if (isMobile) {
    return (
      <>
        <span className="sr-only" aria-hidden data-intent={intent} />
        <Sheet
          isFloat
          isOpen={isOpenOnMobile}
          onOpenChange={setIsOpenOnMobile}

          aria-label="Sidebar"
          data-slot="sidebar"
          data-intent="default"
          className="entering:blur-in exiting:blur-out w-(--sidebar-width) [--sidebar-width:18rem] has-data-[slot=calendar]:[--sidebar-width:23rem]"
          position={side}
        >
          <Heading slot="title" className="sr-only">
            Sidebar
          </Heading>
          {children}
          {closeButton && <DialogCloseIcon isDismissable />}
        </Sheet>
      </>
    )
  }

  return (
    <div
      data-state={state}
      data-collapsible={state === "collapsed" ? collapsible : ""}
      data-intent={intent}
      data-side={side}
      data-slot="sidebar"
      className="group peer text-sidebar-foreground hidden md:block"
      {...props}
    >
      <div
        data-slot="sidebar-gap"
        aria-hidden="true"
        className={cn([
          "w-(--sidebar-width) group-data-[collapsible=hidden]:w-0",
          "group-data-[side=right]:-rotate-180",
          "relative h-svh bg-transparent transition-[width] duration-200 ease-linear",
          intent === "default" && "group-data-[collapsible=dock]:w-(--sidebar-width-dock)",
          intent === "float" &&
            "group-data-[collapsible=dock]:w-[calc(var(--sidebar-width-dock)+(--spacing(4)))]",
          intent === "inset" &&
            "group-data-[collapsible=dock]:w-[calc(var(--sidebar-width-dock)+(--spacing(2)))]",
        ])}
      />
      <div
        data-slot="sidebar-container"
        className={cn(
          "fixed inset-y-0 z-10 hidden w-(--sidebar-width) bg-sidebar not-has-data-[slot=sidebar-footer]:pb-2 md:flex",
          "transition-[left,right,width] duration-200 ease-linear",
          side === "left" && "left-0 group-data-[collapsible=hidden]:-left-(--sidebar-width)",
          side === "right" && "right-0 group-data-[collapsible=hidden]:-right-(--sidebar-width)",
          intent === "float" &&
            "bg-background p-2 group-data-[collapsible=dock]:w-[calc(--spacing(4)+2px)]",
          intent === "inset" &&
            "group-data-[collapsible=dock]:w-[calc(var(--sidebar-width-dock)+(--spacing(2))+2px)] dark:bg-background",
          intent === "default" && [
            "group-data-[collapsible=dock]:w-(--sidebar-width-dock)",
            "border-sidebar-border group-data-[side=left]:border-r group-data-[side=right]:border-l",
          ],
          className
        )}
        {...props}
      >
        <div
          data-sidebar="default"
          data-slot="sidebar-inner"
          className="text-sidebar-foreground group-data-[intent=float]:border-sidebar-border group-data-[intent=float]:bg-sidebar flex size-full flex-col group-data-[intent=float]:rounded-lg group-data-[intent=float]:border group-data-[intent=float]:shadow-xs"
        >
          {children}
        </div>
      </div>
    </div>
  )
}

export function SidebarHeader({ className, ref, ...props }: React.ComponentProps<"div">) {
  const { state } = useSidebar()
  return (
    <div
      ref={ref}
      data-slot="sidebar-header"
      className={cn(
        "flex flex-col gap-2 p-2.5 [.border-b]:border-sidebar-border",
        "in-data-[intent=inset]:p-4",
        state === "collapsed" ? "items-center p-2.5" : "p-4",
        className
      )}
      {...props}
    />
  )
}

export function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="sidebar-footer"
      className={cn([
        "mt-auto flex shrink-0 items-center justify-center p-4 **:data-[slot=chevron]:text-muted-foreground",
        "in-data-[intent=inset]:px-6 in-data-[intent=inset]:py-4",
        className,
      ])}
      {...props}
    />
  )
}

export function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
  const { state } = useSidebar()
  const [isAtBottom, setIsAtBottom] = useState(false)
  const ref = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const el = ref.current
    if (!el) return

    const check = () => {
      const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 1
      setIsAtBottom(atBottom)
    }

    check()
    el.addEventListener("scroll", check, { passive: true })

    const observer = new ResizeObserver(check)
    observer.observe(el)

    return () => {
      el.removeEventListener("scroll", check)
      observer.disconnect()
    }
  }, [])

  return (
    <div
      ref={ref}
      data-slot="sidebar-content"
      className={cn(
        "flex min-h-0 flex-1 scroll-mb-96 flex-col overflow-auto *:data-[slot=sidebar-section]:border-l-0",
        state === "collapsed" ? "items-center" : !isAtBottom && "mask-b-from-95%",
        className
      )}
      {...props}
    >
      {props.children}
    </div>
  )
}

export function SidebarSectionGroup({ className, ...props }: React.ComponentProps<"section">) {
  const { state, isMobile } = useSidebar()
  const collapsed = state === "collapsed" && !isMobile
  return (
    <section
      data-slot="sidebar-section-group"
      className={cn(
        "flex w-full min-w-0 flex-col gap-y-0.5",
        collapsed && "items-center justify-center",
        className
      )}
      {...props}
    />
  )
}

export interface SidebarSectionProps extends React.ComponentProps<"div"> {
  label?: string
}

export function SidebarSection({ className, ...props }: SidebarSectionProps) {
  const { state } = useSidebar()
  return (
    <div
      data-slot="sidebar-section"
      className={cn(
        "col-span-full flex min-w-0 flex-col gap-y-0.5 **:data-[slot=sidebar-section]:**:gap-y-0",
        "in-data-[state=collapsed]:p-2 p-4",
        className
      )}
      {...props}
    >
      {state !== "collapsed" && "label" in props && (
        <Header className="text-sidebar-foreground/70 ring-sidebar-ring mb-1 flex shrink-0 items-center rounded-md px-2 text-xs/6 transition-[margin,opa] duration-200 ease-linear outline-none group-data-[collapsible=dock]:-mt-8 group-data-[collapsible=dock]:opacity-0 *:[svg]:size-4 *:[svg]:shrink-0">
          {props.label}
        </Header>
      )}
      <div
        data-slot="sidebar-section-inner"
        className="grid grid-cols-[auto_1fr] gap-y-0.5 in-data-[state=collapsed]:gap-y-1.5 *:data-[slot=control]:col-span-full"
      >
        {props.children}
      </div>
    </div>
  )
}

export interface SidebarItemProps extends Omit<React.ComponentProps<typeof Link>, "children"> {
  isCurrent?: boolean
  children?:
    | React.ReactNode
    | ((
        values: LinkRenderProps & { defaultChildren: React.ReactNode; isCollapsed: boolean }
      ) => React.ReactNode)
  tooltip?: string | React.ComponentProps<typeof Tooltip>
}

export function SidebarItem({
  isCurrent,
  tooltip,
  children,
  className,
  ref,
  ...props
}: SidebarItemProps) {
  const { state, isMobile } = useSidebar()
  const isCollapsed = state === "collapsed" && !isMobile
  const link = (
    <Link
      ref={ref}
      data-slot="sidebar-item"
      aria-current={isCurrent ? "page" : undefined}
      className={composeRenderProps(
        className,
        (className, { isFocusVisible, isPressed, isHovered, isDisabled }) =>
          cn(
            "w-full min-w-0 items-center rounded-lg p-2 text-start font-medium text-base/6 text-sidebar-foreground has-[a]:p-0",
            "group/sidebar-item relative col-span-full overflow-hidden focus-visible:outline-hidden",
            "grid grid-cols-[auto_1fr_1.5rem_0.5rem_auto] supports-[grid-template-columns:subgrid]:grid-cols-subgrid sm:text-sm/5 **:last:[svg]:ms-auto",
            // icon
            "[&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground **:[svg]:shrink-0",
            "**:last:[svg]:size-5 sm:**:last:[svg]:size-4",
            "[&:has(svg+[data-slot=sidebar-label])_svg:has(+[data-slot=sidebar-label])]:me-2",

            // avatar
            "**:data-[slot=avatar]:[--avatar-size:--spacing(5)]",
            "[&:has([data-slot=avatar]+[data-slot=sidebar-label])_[data-slot=avatar]:has(+[data-slot=sidebar-label])]:me-2",
            "[--sidebar-current-background:var(--color-sidebar-primary)] [--sidebar-current-foreground:var(--color-sidebar-primary-foreground)]",
            isCurrent &&
              "font-medium text-(--sidebar-current-foreground) hover:bg-(--sidebar-current-background) hover:text-(--sidebar-current-foreground) [&_.text-muted-foreground]:text-foreground/80 [&_svg:not([class*='text-'])]:text-(--sidebar-current-foreground) hover:[&_svg:not([class*='text-'])]:text-(--sidebar-current-foreground)",
            isFocusVisible && "inset-ring inset-ring-sidebar-ring outline-hidden",
            isPressed &&
              "bg-sidebar-accent text-sidebar-accent-foreground [&_svg:not([class*='text-'])]:text-sidebar-accent-foreground",
            isHovered &&
              "bg-sidebar-accent text-sidebar-accent-foreground [&_svg:not([class*='text-'])]:text-sidebar-accent-foreground",
            isDisabled && "opacity-50",
            className
          )
      )}
      {...props}
    >
      {(values) => (
        <>{typeof children === "function" ? children({ ...values, isCollapsed }) : children}</>
      )}
    </Link>
  )
  if (typeof tooltip === "string") {
    tooltip = {
      children: tooltip,
    }
  }

  return (
    <TooltipTrigger delay={0}>
      {link}
      <Tooltip
        className="**:data-[slot=sidebar-label-mask]:hidden **:[svg]:hidden"
        inverse
        placement="right"
        arrow
        hidden={!isCollapsed || isMobile || !tooltip}
        {...tooltip}
      />
    </TooltipTrigger>
  )
}

export function SidebarBadge({ className, ...props }: React.ComponentProps<"span">) {
  const { state, isMobile } = useSidebar()

  if (state === "collapsed" && !isMobile) {
    return (
      <span
        aria-hidden
        data-slot="sidebar-badge"
        className="bg-primary absolute end-1 top-1 size-1.5 rounded-full"
      />
    )
  }

  return (
    <span
      data-slot="sidebar-badge"
      className={cn(
        "absolute inset-ring-1 inset-ring-sidebar-border inset-y-1/2 end-1.5 h-5.5 w-auto -translate-y-1/2 rounded-full bg-foreground/5 px-2 text-[10px]/5.5 group-hover/sidebar-item:inset-ring-muted-foreground/30 group-current:inset-ring-transparent",
        className
      )}
      {...props}
    />
  )
}

export interface SidebarLinkProps extends LinkProps {
  ref?: React.RefObject<HTMLAnchorElement>
}

export function SidebarLink({ className, ref, ...props }: SidebarLinkProps) {
  return (
    <Link
      ref={ref}
      className={cx(
        "col-span-full min-w-0 shrink-0 items-center p-2 focus:outline-hidden",
        "grid grid-cols-[auto_1fr_1.5rem_0.5rem_auto] supports-[grid-template-columns:subgrid]:grid-cols-subgrid",
        className
      )}
      {...props}
    />
  )
}

export function SidebarInset({ className, ref, ...props }: React.ComponentProps<"main">) {
  return (
    <main
      data-slot="sidebar-inset"
      ref={ref}
      className={cn(
        "relative flex w-full flex-1 flex-col bg-background lg:min-w-0",
        "md:group-has-data-[intent=inset]/sidebar-root:border group-has-data-[intent=inset]/sidebar-root:border-sidebar-border group-has-data-[intent=inset]/sidebar-root:bg-muted",
        "md:group-has-data-[intent=inset]/sidebar-root:m-2",
        "md:group-has-data-[side=left]:group-has-data-[intent=inset]/sidebar-root:ms-0",
        "md:group-has-data-[side=right]:group-has-data-[intent=inset]/sidebar-root:me-0",
        "md:group-has-data-[intent=inset]/sidebar-root:rounded-2xl",
        "md:group-has-data-[intent=inset]/sidebar-root:peer-data-[state=collapsed]:ms-2",
        className
      )}
      {...props}
    />
  )
}

export type SidebarDisclosureGroupProps = DisclosureGroupProps
export function SidebarDisclosureGroup({
  allowsMultipleExpanded = true,
  className,
  ...props
}: SidebarDisclosureGroupProps) {
  return (
    <DisclosureGroup
      data-slot="sidebar-disclosure-group"
      allowsMultipleExpanded={allowsMultipleExpanded}
      className={cx(
        "col-span-full flex min-w-0 flex-col gap-y-0.5 in-data-[state=collapsed]:gap-y-1.5",
        className
      )}
      {...props}
    />
  )
}

export interface SidebarDisclosureProps extends DisclosureProps {
  ref?: React.Ref<HTMLDivElement>
}

export function SidebarDisclosure({ className, ref, ...props }: SidebarDisclosureProps) {
  const { state } = useSidebar()
  return (
    <Disclosure
      ref={ref}
      data-slot="sidebar-disclosure"
      className={cx("col-span-full min-w-0", state === "collapsed" ? "px-2" : "px-4", className)}
      {...props}
    />
  )
}

export interface SidebarDisclosureTriggerProps extends ButtonProps {
  ref?: React.Ref<HTMLButtonElement>
}

export function SidebarDisclosureTrigger({
  className,
  ref,
  ...props
}: SidebarDisclosureTriggerProps) {
  const { state } = useSidebar()
  return (
    <Heading level={3}>
      <PrimitiveButton
        ref={ref}
        slot="trigger"
        className={composeRenderProps(
          className,
          (className, { isPressed, isFocusVisible, isHovered, isDisabled }) =>
            cn(
              "flex w-full min-w-0 items-center rounded-lg text-start font-medium text-base/6 text-sidebar-foreground",
              "group/sidebar-disclosure-trigger relative col-span-full overflow-hidden focus-visible:outline-hidden",
              "**:[svg]:size-5 **:[svg]:shrink-0 **:[svg]:text-muted-foreground sm:**:[svg]:size-4",
              "**:last:[svg]:size-5 sm:**:last:[svg]:size-4",
              "**:data-[slot=avatar]:size-6 sm:**:data-[slot=avatar]:size-5",
              "col-span-full gap-3 p-2 **:data-[slot=chevron]:text-muted-foreground sm:gap-2 sm:text-sm/5 **:last:[svg]:ms-auto",

              isFocusVisible && "inset-ring inset-ring-ring/70",
              (isPressed || isHovered) &&
                "bg-sidebar-accent text-sidebar-accent-foreground **:data-[slot=chevron]:text-sidebar-accent-foreground **:[svg]:text-sidebar-accent-foreground **:last:[svg]:text-sidebar-accent-foreground",
              isDisabled && "opacity-50",
              className
            )
        )}
        {...props}
      >
        {(values) => (
          <>
            {typeof props.children === "function" ? props.children(values) : props.children}
            {state !== "collapsed" && (
              <ChevronDownIcon
                data-slot="chevron"
                className="z-10 ms-auto size-3.5 transition-transform duration-200 group-aria-expanded/sidebar-disclosure-trigger:rotate-180"
              />
            )}
          </>
        )}
      </PrimitiveButton>
    </Heading>
  )
}

export function SidebarDisclosurePanel({ className, ...props }: DisclosurePanelProps) {
  return (
    <DisclosurePanel
      data-slot="sidebar-disclosure-panel"
      className={cx(
        "h-(--disclosure-panel-height) overflow-clip transition-[height] duration-200",
        className
      )}
      {...props}
    >
      <div
        data-slot="sidebar-disclosure-panel-content"
        className="col-span-full grid grid-cols-[auto_1fr] gap-y-0.5 in-data-[state=collapsed]:gap-y-1.5"
      >
        {props.children}
      </div>
    </DisclosurePanel>
  )
}

export function SidebarSeparator({ className, ...props }: SeparatorProps) {
  return (
    <Separator
      data-slot="sidebar-separator"
      orientation="horizontal"
      className={cn(
        "mx-auto h-px w-[calc(var(--sidebar-width)-(--spacing(10)))] border-0 bg-sidebar-border forced-colors:bg-[ButtonBorder]",
        className
      )}
      {...props}
    />
  )
}

export function SidebarTrigger({
  onPress,
  className,
  children,
  ...props
}: React.ComponentProps<typeof Button>) {
  const { toggleSidebar } = useSidebar()
  return (
    <Button
      aria-label={props["aria-label"] || "Toggle Sidebar"}
      data-slot="sidebar-trigger"
      intent={props.intent || "plain"}
      size={props.size || "sq-sm"}
      className={cx("shrink-0", className)}
      onPress={(event) => {
        onPress?.(event)
        toggleSidebar()
      }}
      {...props}
    >
      {children || (
        <>
          <svg
            className="size-4"
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 16 16"
            width={16}
            height={16}
            fill="currentcolor"
          >
            <path d="M13.25 2.5c.69 0 1.25.56 1.25 1.25v8.5c0 .69-.56 1.25-1.25 1.25H7.5V15h5.75A2.75 2.75 0 0 0 16 12.25v-8.5A2.75 2.75 0 0 0 13.25 1H7.5v1.5zM5.75 1a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-3A2.75 2.75 0 0 1 0 12.25v-8.5A2.75 2.75 0 0 1 2.75 1z" />
          </svg>
          <span className="sr-only">Toggle Sidebar</span>
        </>
      )}
    </Button>
  )
}

export function SidebarRail({ className, ref, ...props }: React.ComponentProps<"button">) {
  const { toggleSidebar } = useSidebar()

  return !props.children ? (
    <button
      ref={ref}
      data-slot="sidebar-rail"
      aria-label="Toggle Sidebar"
      title="Toggle Sidebar"
      tabIndex={-1}
      onClick={toggleSidebar}
      className={cn(
        "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 outline-hidden transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-0.5 hover:after:bg-transparent group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
        "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
        "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
        "group-data-[collapsible=hidden]:translate-x-0 group-data-[collapsible=hidden]:hover:bg-sidebar-accent group-data-[collapsible=hidden]:after:left-full",
        "[[data-side=left][data-collapsible=hidden]_&]:-right-2 [[data-side=right][data-collapsible=hidden]_&]:-left-2",
        className
      )}
      {...props}
    />
  ) : (
    props.children
  )
}

export function SidebarLabel({ className, ref, ...props }: React.ComponentProps<"span">) {
  const { state, isMobile } = useSidebar()
  const collapsed = state === "collapsed" && !isMobile
  if (!collapsed) {
    return (
      <span
        data-slot="sidebar-label"
        tabIndex={-1}
        ref={ref}
        className={cn("col-start-2 truncate pe-6 outline-hidden", className)}
        {...props}
      />
    )
  }
  return null
}

export interface SidebarNavProps extends React.ComponentProps<"nav"> {
  isSticky?: boolean
}

export function SidebarNav({ isSticky = false, className, ...props }: SidebarNavProps) {
  return (
    <nav
      data-slot="sidebar-nav"
      className={cn(
        "isolate flex items-center justify-between gap-x-2 px-(--container-padding,--spacing(4)) py-2.5 text-navbar-foreground sm:justify-start sm:px-(--gutter,--spacing(4)) md:w-full",
        isSticky && "static top-0 z-40 group-has-data-[intent=default]/sidebar-root:sticky",
        className
      )}
      {...props}
    />
  )
}

export interface SidebarMenuTriggerProps extends ButtonProps {
  alwaysVisible?: boolean
}
export function SidebarMenuTrigger({
  alwaysVisible = false,
  className,
  ...props
}: SidebarMenuTriggerProps) {
  return (
    <PrimitiveButton
      className={cx(
        !alwaysVisible &&
          "pressed:text-foreground text-muted-foreground pressed:opacity-100 hover:text-foreground opacity-0",
        "absolute end-0 flex h-full w-[calc(var(--sidebar-width)-90%)] items-center justify-end pe-2.5 outline-hidden",
        "pressed:[&_svg:not([class*='text-'])]:text-foreground [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4 **:[svg]:shrink-0",
        "group/sidebar-item:pressed:opacity-100 group-hover/sidebar-item:opacity-100 group-focus-visible/sidebar-item:opacity-100",
        "group/tree-item:pressed:opacity-100 group-hover/tree-item:opacity-100 group-focus-visible/tree-item:opacity-100",
        className
      )}
      {...props}
    />
  )
}

export interface SidebarTreeProps<T extends object> extends NavigationTreeProps<T> {}
export function SidebarTree<T extends object>({ className, ...props }: SidebarTreeProps<T>) {
  return (
    <NavigationTree
      className={cx(
        "col-span-full flex w-full min-w-0 cursor-default flex-col gap-y-0.5 p-4 outline-hidden forced-color-adjust-none in-data-[state=collapsed]:p-2",
        className
      )}
      {...props}
    />
  )
}

export function SidebarTreeItem<T extends object>({
  className,
  ...props
}: NavigationTreeItemProps<T>) {
  return (
    <NavigationTreeItem
      className={cx(
        "min-w-0 shrink-0 cursor-default outline-hidden select-none",
        "href" in props && "cursor-pointer",
        className
      )}
      {...props}
    />
  )
}

export interface SidebarTreeContentProps extends NavigationTreeItemContentProps {
  className?: string
  isCurrent?: boolean
}

export function SidebarTreeContent({ className, isCurrent, children }: SidebarTreeContentProps) {
  return (
    <NavigationTreeItemContent>
      {(values) => (
        <div data-slot="sidebar-item-content" className="relative flex w-full min-w-0 items-center">
          <div
            aria-hidden
            className="shrink-0"
            style={{
              width: `calc((var(--tree-item-level) - 1) * 1.25rem)`,
            }}
          />
          <div
            className={cn(
              "group/tree-item flex min-w-0 flex-1 items-center gap-x-2 rounded-lg p-2 font-medium text-base/6 text-sidebar-foreground sm:text-sm/5",
              "[&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground **:[svg]:-mx-0.5 **:[svg]:shrink-0",
              "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:[&_svg:not([class*='text-'])]:text-sidebar-accent-foreground",
              "[--sidebar-current-background:var(--color-sidebar-primary)] [--sidebar-current-foreground:var(--color-sidebar-primary-foreground)]",
              values.isFocusVisible && "inset-ring inset-ring-sidebar-ring",
              values.isPressed &&
                "bg-sidebar-accent text-sidebar-accent-foreground [&_svg:not([class*='text-'])]:text-sidebar-accent-foreground",
              (values.isCurrent || isCurrent) &&
                "font-medium text-(--sidebar-current-foreground) hover:bg-(--sidebar-current-background) hover:text-(--sidebar-current-foreground) [&_.text-muted-foreground]:text-foreground/80 [&_svg:not([class*='text-'])]:text-(--sidebar-current-foreground) hover:[&_svg:not([class*='text-'])]:text-(--sidebar-current-foreground)",
              values.isCurrentAncestor &&
                "text-sidebar-accent-foreground [&_svg:not([class*='text-'])]:text-sidebar-accent-foreground",
              values.isDisabled && "opacity-50",
              className
            )}
          >
            {values.hasChildItems && (
              <TreeIndicator
                values={{
                  isDisabled: values.isDisabled,
                  isExpanded: values.isExpanded,
                }}
              />
            )}
            {typeof children === "function" ? children(values) : children}
          </div>
        </div>
      )}
    </NavigationTreeItemContent>
  )
}

export interface SidebarTreeLinkProps extends Omit<LinkProps, "href"> {}

export function SidebarTreeLink({ className, ...props }: SidebarTreeLinkProps) {
  return (
    <Link
      data-slot="sidebar-tree-link"
      className={cx(
        "flex min-w-0 flex-1 items-center gap-x-2 outline-hidden focus-visible:outline-hidden",
        className
      )}
      {...props}
    />
  )
}

export function SidebarTreeLabel({ className, ...props }: React.ComponentProps<"span">) {
  const { state, isMobile } = useSidebar()

  if (state === "collapsed" && !isMobile) {
    return null
  }

  return (
    <span
      data-slot="sidebar-label"
      className={cn("col-start-2 truncate pe-6 outline-hidden", className)}
      {...props}
    />
  )
}
Where it goes
sidebardashboard-shell
Use cases
dashboard

DependenciesBundle weight · light

@heroicons/reactreact-aria-components