{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "app-shell",
  "title": "App Shell",
  "description": "The frame of an app: sidebar, top bar, list and detail, or one reading column, with a skip link and landmarks, following its own width rather than the viewport.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/sidebar.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/app-shell.tsx",
      "content": "import type * as React from \"react\"\r\nimport { useId, useLayoutEffect, useRef, useState } from \"react\"\r\nimport { CenteredLayout, SidebarLayout, SplitLayout, TopbarLayout, WIDE_CONTAINER_PX } from \"@/registry/miseui/ui/app-shell-layouts\"\r\nimport { SidebarProvider, type SidebarEffect, type SidebarVariant } from \"@/registry/miseui/ui/sidebar\"\r\nimport { cn } from \"@/registry/miseui/lib/utils\"\r\n\r\ntype AppShellVariant = \"sidebar\" | \"topbar\" | \"split\" | \"centered\"\r\n\r\ninterface AppShellProps extends Omit<React.ComponentProps<\"div\">, \"title\" | \"ref\"> {\r\n  /** `sidebar` a column beside the page, `topbar` navigation across the top, `split` a list and its detail, `centered` one reading column. */\r\n  variant?: AppShellVariant\r\n  /** Fills its parent instead of the viewport, for a shell inside a panel or a frame. */\r\n  fill?: boolean\r\n  /** Top bar, left. Wraps rather than truncates: a page that cannot name itself is lost. */\r\n  title?: React.ReactNode\r\n  /** Top bar, right. On phones, keep only what fits beside the title. */\r\n  actions?: React.ReactNode\r\n  skipLabel?: string\r\n  /** `sidebar`: a Sidebar. Set its look with `sidebarVariant`. */\r\n  sidebar?: React.ReactNode\r\n  sidebarVariant?: SidebarVariant\r\n  sidebarEffect?: SidebarEffect\r\n  collapsed?: boolean\r\n  defaultCollapsed?: boolean\r\n  onCollapsedChange?: (collapsed: boolean) => void\r\n  /** Mod+B toggles the sidebar. */\r\n  shortcut?: boolean\r\n  /** `topbar`: the links. They move to their own scrolling row on a phone. */\r\n  nav?: React.ReactNode\r\n  navLabel?: string\r\n  /** `split`: the left pane. */\r\n  list?: React.ReactNode\r\n  listLabel?: string\r\n  detailLabel?: string\r\n  /** `split`: on a phone one pane shows at a time, and this says which. */\r\n  detailOpen?: boolean\r\n  /** `split`: draws the phone's way back to the list. */\r\n  onBack?: () => void\r\n  backLabel?: string\r\n}\r\n\r\n/** Measured so the sidebar's drawer can follow the shell's own width, which a media query cannot see. */\r\nfunction useNarrow(ref: React.RefObject<HTMLElement | null>) {\r\n  const [narrow, setNarrow] = useState(false)\r\n  useLayoutEffect(() => {\r\n    const element = ref.current\r\n    if (!element || typeof ResizeObserver === \"undefined\") return\r\n    const observer = new ResizeObserver(([entry]) => setNarrow(entry.contentRect.width < WIDE_CONTAINER_PX))\r\n    observer.observe(element)\r\n    return () => observer.disconnect()\r\n  }, [ref])\r\n  return narrow\r\n}\r\n\r\nfunction SkipLink({ target, targetId, label }: { target: React.RefObject<HTMLElement | null>; targetId: string; label: string }) {\r\n  return (\r\n    <a\r\n      data-slot=\"app-shell-skip\"\r\n      href={`#${targetId}`}\r\n      // A hash link would fight a hash router, and Safari does not move focus for it anyway.\r\n      onClick={(event) => {\r\n        event.preventDefault()\r\n        target.current?.focus()\r\n      }}\r\n      className=\"sr-only focus:not-sr-only focus:absolute focus:start-2 focus:top-2 focus:z-50 focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:shadow-md focus:ring-[3px] focus:ring-ring/50 focus:outline-none\"\r\n    >\r\n      {label}\r\n    </a>\r\n  )\r\n}\r\n\r\nfunction AppShell({\r\n  variant = \"sidebar\",\r\n  fill = false,\r\n  title,\r\n  actions,\r\n  skipLabel = \"Skip to content\",\r\n  sidebar,\r\n  sidebarVariant = \"plain\",\r\n  sidebarEffect,\r\n  collapsed,\r\n  defaultCollapsed,\r\n  onCollapsedChange,\r\n  shortcut,\r\n  nav,\r\n  navLabel = \"Primary\",\r\n  list,\r\n  listLabel = \"List\",\r\n  detailLabel = \"Detail\",\r\n  detailOpen = false,\r\n  onBack,\r\n  backLabel = \"Back\",\r\n  className,\r\n  children,\r\n  ...props\r\n}: AppShellProps) {\r\n  const root = useRef<HTMLDivElement>(null)\r\n  const mainRef = useRef<HTMLElement>(null)\r\n  const mainId = useId()\r\n  const narrow = useNarrow(root)\r\n  const main = { id: mainId, ref: mainRef, tabIndex: -1 as const, \"data-slot\": \"app-shell-main\" as const }\r\n  const shared = { main, fill, title, actions, children }\r\n  const sizeClass = fill ? \"h-full overflow-hidden\" : \"min-h-dvh min-[36rem]:h-dvh\"\r\n  return (\r\n    <div ref={root} data-slot=\"app-shell\" data-variant={variant} className={cn(\"@container relative w-full min-w-0 bg-background text-foreground\", sidebarVariant === \"inset\" && variant === \"sidebar\" && \"bg-muted\", sizeClass, className)} {...props}>\r\n      <SkipLink target={mainRef} targetId={mainId} label={skipLabel} />\r\n      {variant === \"sidebar\" ? (\r\n        <SidebarProvider variant={sidebarVariant} effect={sidebarEffect} collapsed={collapsed} defaultCollapsed={defaultCollapsed} onCollapsedChange={onCollapsedChange} shortcut={shortcut} mobile={narrow}>\r\n          <SidebarLayout {...shared} sidebar={sidebar} inset={sidebarVariant === \"inset\"} />\r\n        </SidebarProvider>\r\n      ) : null}\r\n      {variant === \"topbar\" ? <TopbarLayout {...shared} nav={nav} navLabel={navLabel} /> : null}\r\n      {variant === \"split\" ? <SplitLayout {...shared} list={list} listLabel={listLabel} detailLabel={detailLabel} detailOpen={detailOpen} onBack={onBack} backLabel={backLabel} narrow={narrow} /> : null}\r\n      {variant === \"centered\" ? <CenteredLayout {...shared} /> : null}\r\n    </div>\r\n  )\r\n}\r\n\r\nexport { AppShell, type AppShellProps, type AppShellVariant }\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/app-shell-layouts.tsx",
      "content": "import type * as React from \"react\"\r\nimport { useEffect, useRef } from \"react\"\r\nimport { ChevronLeft } from \"lucide-react\"\r\nimport { SidebarTrigger } from \"@/registry/miseui/ui/sidebar\"\r\nimport { cn } from \"@/registry/miseui/lib/utils\"\r\n\r\n/** Wide means the container, not the viewport, so a shell inside a panel behaves like one on a small screen. */\r\nconst WIDE_CONTAINER_PX = 576\r\nconst TOP_BAR_CLASS = \"sticky top-0 z-10 flex min-h-14 shrink-0 items-center gap-2 border-b bg-background px-4\"\r\nconst COLUMN_CLASS = \"flex h-full min-h-[inherit] min-w-0 flex-1 flex-col\"\r\nconst MAIN_CLASS = \"min-w-0 flex-1 outline-none @xl:overflow-y-auto\"\r\n/** Narrow, the page itself scrolls so a phone's browser bar can collapse. In a fixed frame there is no page, so the column does. */\r\nconst scrollClass = (fill: boolean) => (fill ? \"overflow-y-auto @xl:overflow-hidden\" : \"@xl:overflow-hidden\")\r\n\r\ninterface MainProps {\r\n  id: string\r\n  ref: React.RefObject<HTMLElement | null>\r\n  tabIndex: -1\r\n  \"data-slot\": \"app-shell-main\"\r\n}\r\n\r\ninterface TopBarSlots {\r\n  title?: React.ReactNode\r\n  actions?: React.ReactNode\r\n}\r\n\r\nfunction ShellTitle({ children }: { children?: React.ReactNode }) {\r\n  if (children === undefined) return null\r\n  return <div className=\"min-w-0 py-2 text-sm font-medium\">{children}</div>\r\n}\r\n\r\nfunction ShellActions({ children }: { children?: React.ReactNode }) {\r\n  if (children === undefined) return <span className=\"ms-auto\" />\r\n  return <div className=\"ms-auto flex shrink-0 items-center gap-2\">{children}</div>\r\n}\r\n\r\ninterface LayoutProps extends TopBarSlots {\r\n  main: MainProps\r\n  fill: boolean\r\n  children?: React.ReactNode\r\n}\r\n\r\ninterface SidebarLayoutProps extends LayoutProps {\r\n  sidebar?: React.ReactNode\r\n  inset: boolean\r\n}\r\n\r\nfunction SidebarLayout({ main, fill, title, actions, sidebar, inset, children }: SidebarLayoutProps) {\r\n  return (\r\n    <div className=\"flex h-full min-h-[inherit]\">\r\n      {sidebar}\r\n      <div className={cn(COLUMN_CLASS, scrollClass(fill), inset && \"bg-background @xl:my-2 @xl:me-2 @xl:h-[calc(100%-1rem)] @xl:rounded-xl @xl:border\")}>\r\n        <header data-slot=\"app-shell-top-bar\" className={cn(TOP_BAR_CLASS, inset && \"@xl:rounded-t-xl\")}>\r\n          <SidebarTrigger />\r\n          <ShellTitle>{title}</ShellTitle>\r\n          <ShellActions>{actions}</ShellActions>\r\n        </header>\r\n        <main {...main} className={cn(MAIN_CLASS, \"p-4\")}>\r\n          {children}\r\n        </main>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n\r\ninterface TopbarLayoutProps extends LayoutProps {\r\n  nav?: React.ReactNode\r\n  navLabel: string\r\n}\r\n\r\n/** One nav in the DOM: wrapping it to its own row on a phone beats rendering it twice and hiding one. */\r\nfunction TopbarLayout({ main, fill, title, actions, nav, navLabel, children }: TopbarLayoutProps) {\r\n  return (\r\n    <div className={cn(COLUMN_CLASS, scrollClass(fill))}>\r\n      <header data-slot=\"app-shell-top-bar\" className={cn(TOP_BAR_CLASS, \"flex-wrap\")}>\r\n        <ShellTitle>{title}</ShellTitle>\r\n        {nav ? (\r\n          <nav aria-label={navLabel} className=\"order-last flex w-full min-w-0 items-center gap-1 overflow-x-auto pb-2 whitespace-nowrap @xl:order-none @xl:w-auto @xl:flex-1 @xl:pb-0 @xl:ps-4\">\r\n            {nav}\r\n          </nav>\r\n        ) : null}\r\n        <ShellActions>{actions}</ShellActions>\r\n      </header>\r\n      <main {...main} className={cn(MAIN_CLASS, \"p-4\")}>\r\n        {children}\r\n      </main>\r\n    </div>\r\n  )\r\n}\r\n\r\ninterface SplitLayoutProps extends LayoutProps {\r\n  list?: React.ReactNode\r\n  listLabel: string\r\n  detailLabel: string\r\n  detailOpen: boolean\r\n  onBack?: () => void\r\n  backLabel: string\r\n  narrow: boolean\r\n}\r\n\r\n/** One pane replaces the other on a phone, and focus left on the hidden one would be lost to the top of the page. */\r\nfunction usePaneFocus(detailOpen: boolean, narrow: boolean) {\r\n  const listPane = useRef<HTMLElement>(null)\r\n  const detailPane = useRef<HTMLElement>(null)\r\n  const wasOpen = useRef(detailOpen)\r\n  useEffect(() => {\r\n    if (wasOpen.current === detailOpen) return\r\n    wasOpen.current = detailOpen\r\n    if (narrow) (detailOpen ? detailPane : listPane).current?.focus()\r\n  }, [detailOpen, narrow])\r\n  return { listPane, detailPane }\r\n}\r\n\r\nfunction SplitLayout({ main, fill, title, actions, list, listLabel, detailLabel, detailOpen, onBack, backLabel, narrow, children }: SplitLayoutProps) {\r\n  const { listPane, detailPane } = usePaneFocus(detailOpen, narrow)\r\n  return (\r\n    <div className={cn(COLUMN_CLASS, scrollClass(fill))}>\r\n      {title !== undefined || actions !== undefined ? (\r\n        <header data-slot=\"app-shell-top-bar\" className={TOP_BAR_CLASS}>\r\n          <ShellTitle>{title}</ShellTitle>\r\n          <ShellActions>{actions}</ShellActions>\r\n        </header>\r\n      ) : null}\r\n      <main {...main} className=\"flex min-h-0 min-w-0 flex-1 flex-col outline-none @xl:flex-row\">\r\n        <section\r\n          ref={listPane}\r\n          tabIndex={-1}\r\n          aria-label={listLabel}\r\n          data-slot=\"app-shell-list\"\r\n          className={cn(\"min-w-0 shrink-0 outline-none @xl:block @xl:w-72 @xl:overflow-y-auto @xl:border-e\", detailOpen && \"hidden\")}\r\n        >\r\n          {list}\r\n        </section>\r\n        <section\r\n          ref={detailPane}\r\n          tabIndex={-1}\r\n          aria-label={detailLabel}\r\n          data-slot=\"app-shell-detail\"\r\n          className={cn(\"min-w-0 flex-1 outline-none @xl:block @xl:overflow-y-auto\", !detailOpen && \"hidden\")}\r\n        >\r\n          {onBack ? (\r\n            <button\r\n              type=\"button\"\r\n              onClick={onBack}\r\n              className=\"m-2 inline-flex h-10 items-center gap-1 rounded-md px-2 text-sm text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 @xl:hidden\"\r\n            >\r\n              <ChevronLeft aria-hidden=\"true\" className=\"size-4\" />\r\n              {backLabel}\r\n            </button>\r\n          ) : null}\r\n          <div className=\"p-4\">{children}</div>\r\n        </section>\r\n      </main>\r\n    </div>\r\n  )\r\n}\r\n\r\nfunction CenteredLayout({ main, fill, title, actions, children }: LayoutProps) {\r\n  return (\r\n    <div className={cn(COLUMN_CLASS, scrollClass(fill))}>\r\n      <header data-slot=\"app-shell-top-bar\" className=\"sticky top-0 z-10 shrink-0 border-b bg-background px-4\">\r\n        <div className=\"mx-auto flex min-h-14 w-full max-w-2xl items-center gap-2\">\r\n          <ShellTitle>{title}</ShellTitle>\r\n          <ShellActions>{actions}</ShellActions>\r\n        </div>\r\n      </header>\r\n      {/* The scrollbar stays at the window's edge while the text keeps a readable measure. */}\r\n      <main {...main} className={MAIN_CLASS}>\r\n        <div className=\"mx-auto w-full max-w-2xl px-4 py-8\">{children}</div>\r\n      </main>\r\n    </div>\r\n  )\r\n}\r\n\r\nexport { CenteredLayout, SidebarLayout, SplitLayout, TopbarLayout, WIDE_CONTAINER_PX, type MainProps }\r\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "type": "registry:ui"
}