{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "sheet",
  "title": "Sheet",
  "type": "registry:ui",
  "description": "Side-mounted modal that slides in from the top, right, bottom, or left edge. Use for filter panels, edit drawers, and mobile menus.",
  "categories": [
    "overlay"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/sheet/sheet.ts",
      "content": "import { LitElement, css, html, nothing, isServer } from 'lit'\nimport { X } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\n\nexport type SheetSide = 'top' | 'right' | 'bottom' | 'left'\nexport type SheetCloseBehavior = 'auto' | 'click-outside' | 'esc' | 'manual' | 'none'\n\nlet uid = 0\n\n// Boolean props whose default is `true`: `attr=\"false\"` turns them off.\nconst trueByDefault = { fromAttribute: (v: string | null) => v !== 'false', toAttribute: () => null }\n\n// React's SheetContent side classes verbatim, plus the insets/sizes needed to\n// undo the UA styles of a modal <dialog> (it is centred with `inset: 0;\n// margin: auto`, sized `fit-content` and capped at `100% - 2em`).\nconst sideClasses: Record<SheetSide, string> = {\n  right: cn(\n    'motion-safe:data-[state=closed]:slide-out-to-right motion-safe:data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',\n    'left-auto',\n  ),\n  left: cn(\n    'motion-safe:data-[state=closed]:slide-out-to-left motion-safe:data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',\n    'right-auto',\n  ),\n  top: cn(\n    'motion-safe:data-[state=closed]:slide-out-to-top motion-safe:data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',\n    'bottom-auto w-auto',\n  ),\n  bottom: cn(\n    'motion-safe:data-[state=closed]:slide-out-to-bottom motion-safe:data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',\n    'top-auto w-auto',\n  ),\n}\n\n// React's SheetOverlay classes verbatim — rendered as a sibling <div> only when\n// `modal` is false (a non-modal <dialog> has no ::backdrop). `pointer-events-none`\n// keeps the page interactive, which is the point of a non-modal sheet;\n// dismissal is handled by a document pointerdown listener instead.\nconst backdropClasses =\n  'motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:ease-emphasized motion-safe:data-[state=open]:blur-in-2 motion-safe:data-[state=closed]:blur-out-2 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:fade-in-0 bg-foreground/50 fixed inset-0 z-50'\n\n/**\n * <uip-sheet> — the registry Sheet (Sheet + SheetTrigger + SheetContent +\n * SheetHeader/Title/Description + SheetFooter + SheetClose) as ONE web\n * component.\n *\n *   <uip-sheet side=\"left\" heading=\"Edit profile\" description=\"…\">\n *     <uip-button slot=\"trigger\" variant=\"outline\">Open</uip-button>\n *     …body…\n *     <uip-button slot=\"footer\">Save changes</uip-button>\n *     <uip-button slot=\"footer\" variant=\"outline\" data-sheet-close>Cancel</uip-button>\n *   </uip-sheet>\n *\n * Built on the native <dialog> + showModal() like <uip-dialog>: top layer, page\n * inert, focus trapped, focus returned to the trigger on close, Escape closes.\n * SheetOverlay is the dialog's ::backdrop.\n *\n * ARIA references can't cross a shadow boundary, so the title/description\n * come from the `heading` / `description` attributes rendered inside the\n * shadow root (`slot=\"title\"` / `slot=\"description\"` accept rich markup).\n * Any slotted element with `data-sheet-close` closes the sheet (SheetClose).\n * The header and footer stay pinned; wrap the body in <uip-sheet-body>\n * (SheetBody) so only that region scrolls, with the sheet's padding.\n *\n * `hide-header`: keeps the header in the accessibility tree but visually hidden\n * (`sr-only`, like React's `<SheetHeader className=\"sr-only\">`), so a sheet\n * without a visible title still has an accessible name and description:\n *\n *   <uip-sheet side=\"left\" hide-header heading=\"Sidebar\" description=\"Displays the mobile sidebar.\">\n *\n * - `modal` (default true; `modal=\"false\"` for non-modal, like Radix's `modal`\n *   prop): non-modal uses `show()` — the page stays interactive (no inert, no\n *   focus trap) and the dimming is a plain `pointer-events-none` backdrop div\n *   (a non-modal dialog has no ::backdrop).\n * - `close-behavior`: `auto` (default) | `click-outside` | `esc` | `manual` |\n *   `none` — which dismissals are allowed (same values as <uip-popover>).\n *   `manual` / `none` block Escape and backdrop dismissal; unlike the popover,\n *   the `cancel` event still fires so consumers can confirm-then-close.\n * - `persist`: localStorage key for the open state (empty attribute = an\n *   auto-generated key), like <uip-popover>'s `persist`.\n *\n * Parts (style from the host with `class=\"[&::part(footer)]:…\"`): `content`\n * (the dialog), `backdrop` (only non-modal: the dimming div), `header`,\n * `title`, `description`, `footer`, `close`.\n *\n * Slots: `trigger`, default (body), `footer`, `title`, `description`.\n * Methods: `show()`, `close()`. Events: `open-change` (detail: { open }),\n * `cancel` (cancelable, detail: { reason: 'escape' | 'backdrop' }) and `close`\n * (cancelable, detail: { reason }) — fired in that order when Escape or a\n * backdrop click requests dismissal; `preventDefault()` on either keeps the\n * sheet open. The close button, `data-sheet-close` elements and `close()`\n * always close (like Radix's DialogClose).\n */\nexport class UipSheet extends LitElement {\n  static styles = [tailwind]\n\n  static properties = {\n    open: { type: Boolean, reflect: true },\n    side: { reflect: true },\n    heading: {},\n    description: {},\n    showCloseButton: { type: Boolean, attribute: 'show-close-button', converter: trueByDefault },\n    hideHeader: { type: Boolean, attribute: 'hide-header' },\n    modal: { converter: trueByDefault },\n    closeBehavior: { attribute: 'close-behavior' },\n    persist: {},\n    state: { state: true },\n    backdropVisible: { state: true },\n    hasFooter: { state: true },\n    hasTitle: { state: true },\n  }\n\n  open = false\n  side: SheetSide = 'right'\n  heading?: string\n  description?: string\n  showCloseButton = true\n  hideHeader = false\n  modal = true\n  closeBehavior: SheetCloseBehavior = 'auto'\n  persist?: string\n  private state: 'open' | 'closed' = 'closed'\n  private backdropVisible = false\n  private hasFooter = false\n  private hasTitle = false\n  private readonly autoKey = `uipkge-sheet-${++uid}`\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'sheet')\n    // Hydrate a persisted open state.\n    const key = this.storageKey\n    if (key && !isServer) {\n      try {\n        if (localStorage.getItem(key) === '1' && !this.open) this.setOpen(true)\n      } catch {\n        /* storage unavailable */\n      }\n    }\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.removeDocListeners()\n  }\n\n  private get storageKey() {\n    if (this.persist === undefined || this.persist === null) return null\n    return this.persist || this.autoKey\n  }\n\n  private get trigger(): HTMLElement | undefined {\n    const slot = this.renderRoot?.querySelector<HTMLSlotElement>('slot[name=\"trigger\"]')\n    return slot?.assignedElements({ flatten: true })[0] as HTMLElement | undefined\n  }\n\n  private get dialog() {\n    return this.renderRoot.querySelector('dialog')\n  }\n\n  show() {\n    this.setOpen(true)\n  }\n\n  close() {\n    this.setOpen(false)\n  }\n\n  private setOpen(open: boolean) {\n    if (this.open === open) return\n    this.open = open\n    this.dispatchEvent(new CustomEvent('open-change', { detail: { open }, bubbles: true, composed: true }))\n  }\n\n  // --- dismissal (closeBehavior, like <uip-dialog> / <uip-popover>) ------------\n  private allows(kind: 'outside' | 'esc') {\n    const b = this.closeBehavior\n    if (b === 'manual' || b === 'none') return false\n    if (kind === 'outside') return b !== 'esc'\n    return b !== 'click-outside'\n  }\n\n  /**\n   * Escape or a backdrop click requests dismissal: fire cancelable `cancel`,\n   * then (unless closeBehavior blocks it) cancelable `close`. Either can be\n   * prevented; the close button / data-sheet-close / close() bypass this.\n   */\n  private requestDismiss(reason: 'escape' | 'backdrop') {\n    if (!this.open) return\n    const cancelEv = new CustomEvent('cancel', {\n      detail: { reason },\n      bubbles: true,\n      composed: true,\n      cancelable: true,\n    })\n    if (!this.dispatchEvent(cancelEv)) return\n    if (!this.allows(reason === 'escape' ? 'esc' : 'outside')) return\n    const closeEv = new CustomEvent('close', {\n      detail: { reason },\n      bubbles: true,\n      composed: true,\n      cancelable: true,\n    })\n    if (!this.dispatchEvent(closeEv)) return\n    this.setOpen(false)\n  }\n\n  // Non-modal only: the browser fires no `cancel` and ::backdrop doesn't exist,\n  // so watch Escape + outside pointerdown on the document (like the popover).\n  private onDocKeyDown = (e: KeyboardEvent) => {\n    if (e.key !== 'Escape' || !this.open || this.modal) return\n    e.stopPropagation()\n    this.requestDismiss('escape')\n  }\n\n  private onDocPointerDown = (e: PointerEvent) => {\n    if (!this.open || this.modal) return\n    const path = e.composedPath()\n    if (this.dialog && path.includes(this.dialog)) return\n    const t = this.trigger\n    if (t && path.includes(t)) return\n    this.requestDismiss('backdrop')\n  }\n\n  private addDocListeners() {\n    addEventListener('pointerdown', this.onDocPointerDown, true)\n    addEventListener('keydown', this.onDocKeyDown, true)\n  }\n\n  private removeDocListeners() {\n    removeEventListener('pointerdown', this.onDocPointerDown, true)\n    removeEventListener('keydown', this.onDocKeyDown, true)\n  }\n\n  private savePersisted() {\n    const key = this.storageKey\n    if (!key) return\n    try {\n      if (this.open) localStorage.setItem(key, '1')\n      else localStorage.removeItem(key)\n    } catch {\n      /* storage unavailable */\n    }\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    if (changed.has('open')) {\n      this.state = this.open ? 'open' : 'closed'\n      // Mount the non-modal backdrop with the enter animation; it unmounts in\n      // done() below so the exit animation plays first.\n      if (this.open) this.backdropVisible = true\n    }\n  }\n\n  protected updated(changed: Map<string, unknown>) {\n    if (isServer) return\n    // SheetTrigger: same attributes React/Radix put on the trigger (see popover.ts).\n    const trigger = this.trigger\n    if (trigger) {\n      trigger.setAttribute('aria-haspopup', 'dialog')\n      trigger.setAttribute('aria-expanded', String(this.open))\n      trigger.setAttribute('data-state', this.state)\n    }\n    if (!changed.has('open') && !changed.has('modal')) return\n    this.savePersisted()\n    if (!this.modal && this.open) this.addDocListeners()\n    else this.removeDocListeners()\n    const dialog = this.dialog\n    if (!dialog) return\n    if (this.open && !dialog.open) {\n      if (this.modal) dialog.showModal()\n      else dialog.show()\n    } else if (this.open && dialog.open && changed.has('modal')) {\n      // A dialog can't switch modes while open — reopen it in the new mode.\n      // (The intermediate close()'s native `close` arrives async, after the\n      // dialog is open again; onClose ignores it by checking dialog.open.)\n      dialog.close()\n      if (this.modal) dialog.showModal()\n      else dialog.show()\n      this.backdropVisible = !this.modal\n    } else if (!this.open && dialog.open) {\n      // Play the exit animation (data-state=closed), then actually close. The\n      // timeout covers tabs where animations never finish (hidden/background).\n      let closed = false\n      const done = () => {\n        if (closed || this.open) return\n        closed = true\n        dialog.close()\n        this.backdropVisible = false\n      }\n      setTimeout(done, 400)\n      const anims = dialog.getAnimations()\n      if (anims.length) Promise.all(anims.map((a) => a.finished)).then(done, done)\n      else done()\n    }\n  }\n\n  // Escape on a modal sheet: the browser fires `cancel`; prevent the native\n  // instant close and route it through the dismiss flow (cancel/close events +\n  // closeBehavior). (Non-modal sheets get no native cancel; onDocKeyDown covers them.)\n  private onCancel(e: Event) {\n    e.preventDefault()\n    this.requestDismiss('escape')\n  }\n\n  // Native `close` arrives async, so it can't tell the exit path's close()\n  // from the mode-switch branch's intermediate close() by timing — check the\n  // dialog instead: only sync `open` off when it really is closed.\n  private onClose() {\n    if (!this.dialog?.open) this.close()\n  }\n\n  // A modal click on the ::backdrop lands on the <dialog> itself. Non-modal\n  // clicks on the dialog box are content clicks — outside dismissal is\n  // onDocPointerDown. SheetClose elements are marked with data-sheet-close.\n  private onDialogClick(e: MouseEvent) {\n    if (e.target === this.dialog && this.modal) return this.requestDismiss('backdrop')\n    const closer = e.composedPath().find((n) => n instanceof Element && n.hasAttribute('data-sheet-close'))\n    if (closer) this.close()\n  }\n\n  private slotText(name: string) {\n    const slot = this.renderRoot.querySelector<HTMLSlotElement>(`slot[name=\"${name}\"]`)\n    return slot?.assignedNodes({ flatten: true }).map((n) => n.textContent ?? '').join(' ').trim() || undefined\n  }\n\n  private onTitleSlotChange() {\n    this.hasTitle = !!this.slotText('title')\n  }\n\n  private onFooterSlotChange(e: Event) {\n    this.hasFooter = (e.target as HTMLSlotElement).assignedNodes().length > 0\n  }\n\n  render() {\n    const slottedTitle = this.hasTitle ? this.slotText('title') : undefined\n    const hasHeader = !!(this.heading || this.description || this.hasTitle)\n    return html`\n      <slot name=\"trigger\" @click=${() => this.show()} @slotchange=${() => this.requestUpdate()}></slot>\n      ${!this.modal && this.backdropVisible\n        ? html`<div\n            part=\"backdrop\"\n            data-uipkge=\"\"\n            data-slot=\"sheet-overlay\"\n            data-state=${this.state}\n            aria-hidden=\"true\"\n            class=${cn(backdropClasses, 'pointer-events-none')}\n          ></div>`\n        : nothing}\n      <dialog\n        part=\"content\"\n        data-uipkge=\"\"\n        data-slot=\"sheet-content\"\n        data-state=${this.state}\n        aria-labelledby=${this.heading ? 'uip-sheet-title' : nothing}\n        aria-label=${!this.heading && slottedTitle ? slottedTitle : nothing}\n        aria-describedby=${this.description ? 'uip-sheet-description' : nothing}\n        class=${cn(\n          'bg-background motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:ease-emphasized motion-safe:data-[state=open]:blur-in-2 motion-safe:data-[state=closed]:blur-out-2 fixed z-50 open:flex flex-col gap-0 overflow-hidden shadow-lg transition ease-in-out motion-safe:data-[state=closed]:duration-200 motion-safe:data-[state=open]:duration-300',\n          // Undo the modal <dialog> UA box (margin, max size) and its `color:\n          // CanvasText` (React's portal inherits the page's text colour).\n          'text-foreground m-0 max-h-none max-w-none backdrop:bg-foreground/50',\n          sideClasses[this.side] ?? sideClasses.right,\n        )}\n        @cancel=${this.onCancel}\n        @close=${this.onClose}\n        @click=${this.onDialogClick}\n      >\n        <div\n          part=\"header\"\n          data-slot=\"sheet-header\"\n          class=${cn('flex shrink-0 flex-col gap-1.5 border-b p-4 pr-12', this.hideHeader && 'sr-only')}\n          ?hidden=${!hasHeader}\n        >\n          <h2 part=\"title\" id=\"uip-sheet-title\" data-slot=\"sheet-title\" class=\"text-foreground font-semibold\">\n            <slot name=\"title\" @slotchange=${this.onTitleSlotChange}>${this.heading}</slot>\n          </h2>\n          <p part=\"description\" id=\"uip-sheet-description\" data-slot=\"sheet-description\" class=\"text-muted-foreground text-sm\">\n            <slot name=\"description\">${this.description}</slot>\n          </p>\n        </div>\n        <slot></slot>\n        <div part=\"footer\" data-slot=\"sheet-footer\" class=\"mt-auto flex shrink-0 flex-col-reverse gap-2 border-t p-4 sm:flex-row sm:justify-end\" ?hidden=${!this.hasFooter}>\n          <slot name=\"footer\" @slotchange=${this.onFooterSlotChange}></slot>\n        </div>\n        ${this.showCloseButton\n          ? html`<button\n              type=\"button\"\n              part=\"close\"\n              data-slot=\"sheet-close\"\n              class=\"text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/50 absolute top-3 right-3 inline-flex size-8 items-center justify-center rounded-md transition-colors outline-none focus-visible:ring-[3px] disabled:pointer-events-none\"\n              @click=${() => this.close()}\n            >\n              ${icon(X, 'x', 'size-4')}\n              <span class=\"sr-only\">Close</span>\n            </button>`\n          : nothing}\n      </dialog>\n    `\n  }\n}\n\n/**\n * <uip-sheet-body> — SheetBody: the scrolling middle of a sheet (put it in\n * <uip-sheet>'s default slot). The header (title + close) and the footer\n * (actions) stay pinned; only this region scrolls. The host is\n * `display: contents`, so its inner div is the sheet's flex item. Style it with `class=\"[&::part(base)]:…\"`.\n */\nexport class UipSheetBody extends LitElement {\n  static styles = [tailwind, css`:host { display: contents; }`]\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'sheet-body')\n  }\n\n  render() {\n    return html`<div part=\"base\" data-slot=\"sheet-body\" class=\"min-h-0 flex-1 overflow-y-auto p-4\">\n      <slot></slot>\n    </div>`\n  }\n}\n\ncustomElements.get('uip-sheet') || customElements.define('uip-sheet', UipSheet)\ncustomElements.get('uip-sheet-body') || customElements.define('uip-sheet-body', UipSheetBody)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-sheet': UipSheet\n    'uip-sheet-body': UipSheetBody\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/sheet/sheet.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
