{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "inline-sheet",
  "title": "Inline Sheet",
  "description": "A sheet from the bottom or the start, or a centred dialog, drawn inside its container instead of over the page: a backdrop and Escape close it, and a bottom sheet can be pulled down by its grip.",
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/utils.json",
    "https://ui.ndekocode.dev/r/button.json",
    "https://ui.ndekocode.dev/r/icons.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/ui/inline-sheet.tsx",
      "content": "'use client';\n\nimport { useRef } from 'react';\n\nimport { XIcon } from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Button } from '@/registry/ndeko/ui/button';\n\nexport type InlineSheetSide = 'bottom' | 'start' | 'center';\n\nexport type InlineSheetCloser = 'button' | 'grip' | 'none';\n\n/** How far the sheet has to be dragged down before letting go closes it. */\nconst CLOSE_AFTER_PX = 80;\n\n/** A grip moved less than this was pressed, not dragged. */\nconst DRAG_SLACK_PX = 4;\n\nconst BACKDROP =\n  'absolute inset-0 cursor-default bg-black/40 transition-opacity duration-200';\n\nconst BACKDROP_BLUR: Record<InlineSheetSide, string | undefined> = {\n  bottom: undefined,\n  start: undefined,\n  center: 'backdrop-blur-xs',\n};\n\nconst PANEL =\n  'bg-card hairline-ring ring-border absolute rounded-2xl shadow-xl shadow-black/8';\n\nconst PANEL_PLACE: Record<InlineSheetSide, string> = {\n  bottom:\n    'inset-x-2 bottom-2 flex flex-col transition-[translate] duration-300 ease-(--ease-drawer)',\n  start:\n    'inset-y-2 start-2 flex w-[19rem] max-w-[calc(100%-3.5rem)] flex-col transition-[translate] duration-300 ease-(--ease-drawer)',\n  center:\n    'top-1/2 left-1/2 max-h-[calc(100%-2rem)] w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 overflow-x-hidden overflow-y-auto transition-[opacity,scale] duration-200 ease-(--ease-out-strong)',\n};\n\nconst PANEL_OPEN: Record<InlineSheetSide, string> = {\n  bottom: 'translate-y-0',\n  start: 'translate-x-0',\n  center: 'scale-100 opacity-100',\n};\n\nconst PANEL_SHUT: Record<InlineSheetSide, string> = {\n  bottom: 'translate-y-[calc(100%+1rem)]',\n  start: '-translate-x-[calc(100%+1rem)]',\n  center: 'scale-95 opacity-0 duration-150',\n};\n\nfunction layerOf(isOpen: boolean) {\n  if (isOpen) {\n    return undefined;\n  }\n  return 'pointer-events-none';\n}\n\nfunction backdropOf(isOpen: boolean) {\n  if (isOpen) {\n    return 'opacity-100';\n  }\n  return 'opacity-0';\n}\n\nfunction panelStateOf(isOpen: boolean, side: InlineSheetSide) {\n  if (isOpen) {\n    return PANEL_OPEN[side];\n  }\n  return PANEL_SHUT[side];\n}\n\n/** A list opened from a field inside is drawn outside the sheet, yet its keys still reach the sheet: Escape there closes the list and nothing else. */\nfunction isEscapeInside(event: React.KeyboardEvent<HTMLElement>) {\n  const target = event.target;\n  const isInside =\n    target instanceof Node && event.currentTarget.contains(target);\n  return event.key === 'Escape' && isInside;\n}\n\nexport type InlineSheetProps = {\n  side: InlineSheetSide;\n  isOpen: boolean;\n  onClose: () => void;\n  /** Names the dialog. */\n  label?: string;\n  /** The id of a heading inside that names the dialog, in place of `label`. */\n  labelledBy?: string;\n  /** The id of a line inside that describes the dialog. */\n  describedBy?: string;\n  /** Names the backdrop and the close control the sheet draws. */\n  closeLabel: string;\n  /**\n   * The control drawn above the content: a close button, a grip that follows the finger and closes\n   * the sheet when pulled far enough (a bottom sheet only), or none when the content brings its own.\n   */\n  closer?: InlineSheetCloser;\n  /** Set as `data-sheet-close` on that control, so the opener can move the focus to it. */\n  name?: string;\n  /**\n   * False when the caller leaves the rest of its container reachable while the sheet is open: the\n   * dialog then does not tell assistive technology that everything else is out of reach.\n   */\n  isModal?: boolean;\n  /** Classes of the layer: the container variant from which the sheet is gone, a z-index. */\n  className?: string;\n  /** Classes of the panel: its surface, padding, height and gap. */\n  panelClassName?: string;\n  children: React.ReactNode;\n};\n\n/**\n * A sheet or a dialog drawn inside its container, so a demonstration never opens a layer over the\n * page; it needs a positioned ancestor. Escape pressed inside it, its backdrop and its close control\n * call `onClose`. A demonstration may open it too, so the caller makes the page behind inert while it\n * is open and moves the focus in, and back to the opener, from a person's own action.\n */\nexport function InlineSheet({\n  side,\n  isOpen,\n  onClose,\n  label,\n  labelledBy,\n  describedBy,\n  closeLabel,\n  closer = 'button',\n  name,\n  isModal = true,\n  className,\n  panelClassName,\n  children,\n}: InlineSheetProps) {\n  const panelRef = useRef<HTMLDivElement>(null);\n  const startY = useRef<number | null>(null);\n  const pulled = useRef(0);\n  const isBottom = side === 'bottom';\n  const hasGripCloser = closer === 'grip';\n  const showsGrip = hasGripCloser && isBottom;\n  const showsButton = closer === 'button';\n\n  function startPull(event: React.PointerEvent<HTMLButtonElement>) {\n    event.currentTarget.setPointerCapture(event.pointerId);\n    startY.current = event.clientY;\n    pulled.current = 0;\n  }\n\n  function pull(event: React.PointerEvent<HTMLButtonElement>) {\n    const panel = panelRef.current;\n    const origin = startY.current;\n    if (!panel || origin === null) {\n      return;\n    }\n    const travelled = event.clientY - origin;\n    const distance = Math.max(0, travelled);\n    pulled.current = distance;\n    panel.style.transition = 'none';\n    panel.style.translate = `0 ${distance}px`;\n  }\n\n  function endPull() {\n    const panel = panelRef.current;\n    const wasPulling = startY.current !== null;\n    startY.current = null;\n    if (!panel || !wasPulling) {\n      return;\n    }\n    panel.style.transition = '';\n    panel.style.translate = '';\n    if (pulled.current > CLOSE_AFTER_PX) {\n      pulled.current = 0;\n      onClose();\n    }\n  }\n\n  function closeFromGrip() {\n    const wasDragged = pulled.current > DRAG_SLACK_PX;\n    pulled.current = 0;\n    if (!wasDragged) {\n      onClose();\n    }\n  }\n\n  function closeOnEscape(event: React.KeyboardEvent<HTMLDivElement>) {\n    if (isEscapeInside(event)) {\n      onClose();\n    }\n  }\n\n  return (\n    <div\n      inert={!isOpen}\n      onKeyDown={closeOnEscape}\n      className={cn('absolute inset-0 z-20', className, layerOf(isOpen))}\n    >\n      <button\n        type='button'\n        tabIndex={-1}\n        aria-label={closeLabel}\n        onClick={onClose}\n        className={cn(BACKDROP, BACKDROP_BLUR[side], backdropOf(isOpen))}\n      />\n      <div\n        ref={panelRef}\n        role='dialog'\n        aria-modal={isModal}\n        aria-label={label}\n        aria-labelledby={labelledBy}\n        aria-describedby={describedBy}\n        className={cn(\n          PANEL,\n          PANEL_PLACE[side],\n          panelClassName,\n          panelStateOf(isOpen, side)\n        )}\n      >\n        {showsGrip && (\n          <button\n            type='button'\n            data-sheet-close={name}\n            aria-label={closeLabel}\n            onClick={closeFromGrip}\n            onPointerDown={startPull}\n            onPointerMove={pull}\n            onPointerUp={endPull}\n            onPointerCancel={endPull}\n            className='group/grip flex h-7 w-full shrink-0 cursor-grab touch-none items-center justify-center outline-none active:cursor-grabbing'\n          >\n            <span className='bg-foreground/20 group-focus-visible/grip:bg-primary h-1 w-9 rounded-full transition-colors duration-150' />\n          </button>\n        )}\n        {showsButton && (\n          <div className='flex shrink-0 justify-end'>\n            <Button\n              type='button'\n              variant='ghost'\n              size='icon-sm'\n              data-sheet-close={name}\n              aria-label={closeLabel}\n              onClick={onClose}\n              className='text-muted-foreground'\n            >\n              <XIcon />\n            </Button>\n          </div>\n        )}\n        {children}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "primitives",
    "ai"
  ],
  "type": "registry:ui"
}