{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"popover-clean","type":"registry:ui","title":"Popover (Clean)","description":"A small floating card attached to its trigger – a title and quiet line, any content, flips to stay on screen, closes on a press outside or Escape and hands the focus back","dependencies":[],"files":[{"path":"registry/popover-clean/popover-clean.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, type ComponentPropsWithoutRef, type ReactNode, type Ref, type RefObject } from \"react\";\n\n/*\n * Popover – a single self-contained file, clean style.\n * A small floating card attached to its trigger: a short form, a few settings, an explanation. It opens on the side\n * you ask for and flips to the other side (or edge) when there is no room, so it is always fully reachable; it lives\n * in the top layer, so no card's overflow cuts it. A press outside, Escape or tabbing away closes it, and the focus\n * goes back to the trigger. It fades and scales in from the side it is attached to.\n * The styles are injected below, so no extra files are needed.\n */\n\nexport type PopoverProps = Omit<ComponentPropsWithoutRef<\"div\">, \"title\" | \"children\"> & {\n  open: boolean;\n  /** Called on a press outside, Escape, or when the focus leaves – set open to false here */\n  onClose: () => void;\n  children?: ReactNode;\n  /** Where it opens; it flips to the other side when there is no room */\n  side?: \"bottom\" | \"top\" | \"right\" | \"left\";\n  /** Which edges line up: start (left, or top beside it) · center · end */\n  align?: \"start\" | \"center\" | \"end\";\n  /** Gap to the trigger in px */\n  offset?: number;\n  /** The element it is attached to – by default its parent element (wrap trigger and Popover in one element) */\n  anchor?: RefObject<HTMLElement | null>;\n  /** A heading at the top – it also names the popover */\n  title?: ReactNode;\n  /** A quiet line under the title – read out with it */\n  description?: ReactNode;\n  /** The card's width – by default it fits its content */\n  width?: number | string;\n  /** Moves the focus to the first control inside when it opens (or onto the card when there is none) */\n  focusOnOpen?: boolean;\n  /** Only the placing and the open/close: no card, padding or type of its own – your className draws it */\n  unstyled?: boolean;\n  ref?: Ref<HTMLDivElement>;\n};\n\nexport function Popover({\n  open,\n  onClose,\n  children,\n  side = \"bottom\",\n  align = \"start\",\n  offset = 6,\n  anchor,\n  title,\n  description,\n  width,\n  focusOnOpen = true,\n  unstyled,\n  className,\n  style,\n  role = \"dialog\",\n  ref,\n  ...rest\n}: PopoverProps) {\n  const uid = useId();\n  const titleId = `${uid}-title`;\n  const descId = `${uid}-desc`;\n  const panel = useRef<HTMLDivElement | null>(null);\n  const close = useRef(onClose);\n  close.current = onClose;\n  // Open from the start (a demo, a restored view) leaves the focus where it is – only an opening moves it\n  const wasClosed = useRef(!open);\n  if (!open) wasClosed.current = true;\n\n  const anchorEl = () => anchor?.current ?? panel.current?.parentElement ?? null;\n  const inside = (n: Node | null) => !!n && (!!panel.current?.contains(n) || !!anchorEl()?.contains(n));\n\n  // Into the top layer and next to the anchor, before paint; it follows on scroll and resize\n  useLayoutEffect(() => {\n    const el = panel.current;\n    if (!open || !el) return;\n    showLayer(el);\n    const update = () => {\n      const a = anchorEl();\n      if (a) place(el, a.getBoundingClientRect(), side, align, offset);\n    };\n    update();\n    window.addEventListener(\"resize\", update);\n    window.addEventListener(\"scroll\", update, true);\n    const ro = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(update);\n    ro?.observe(el);\n    return () => {\n      window.removeEventListener(\"resize\", update);\n      window.removeEventListener(\"scroll\", update, true);\n      ro?.disconnect();\n      hideLayer(el);\n    };\n  }, [open, side, align, offset]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  // The focus moves in; however it closes, if the focus was inside, the trigger gets it back\n  useEffect(() => {\n    const el = panel.current;\n    if (!open || !el) return;\n    if (focusOnOpen && wasClosed.current && !el.contains(document.activeElement)) (firstControl(el) ?? el).focus({ preventScroll: true });\n    const trigger = triggerOf(anchorEl(), el);\n    let within = el.contains(document.activeElement);\n    const into = () => (within = true);\n    const away = (e: FocusEvent) => (within = !!e.relatedTarget && el.contains(e.relatedTarget as Node));\n    el.addEventListener(\"focusin\", into);\n    el.addEventListener(\"focusout\", away);\n    return () => {\n      el.removeEventListener(\"focusin\", into);\n      el.removeEventListener(\"focusout\", away);\n      if (within) refocus(trigger);\n    };\n  }, [open]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  useEffect(() => {\n    if (!open) return;\n    const down = (e: PointerEvent) => {\n      if (!inside(e.target as Node)) close.current();\n    };\n    // Caught first (window, capture), so a page's own Escape – e.g. closing a detail view – doesn't fire as well\n    const key = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\" || e.defaultPrevented) return;\n      // A menu or another popover opened from inside this one closes first\n      const layer = (e.target as Element | null)?.closest?.(\"[popover]\");\n      if (layer && layer !== panel.current && panel.current?.contains(layer)) return;\n      e.preventDefault();\n      e.stopPropagation();\n      const back = inside(document.activeElement);\n      const trigger = triggerOf(anchorEl(), panel.current);\n      close.current();\n      if (back) trigger?.focus({ preventScroll: true });\n    };\n    // Tabbing out of the trigger and the popover closes it\n    const out = (e: FocusEvent) => {\n      const to = e.relatedTarget as Node | null;\n      if (inside(e.target as Node) && to && !inside(to)) close.current();\n    };\n    document.addEventListener(\"pointerdown\", down, true);\n    window.addEventListener(\"keydown\", key, true);\n    document.addEventListener(\"focusout\", out);\n    return () => {\n      document.removeEventListener(\"pointerdown\", down, true);\n      window.removeEventListener(\"keydown\", key, true);\n      document.removeEventListener(\"focusout\", out);\n    };\n  }, [open]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  if (!open) return null;\n  const hasHead = shows(title) || shows(description);\n  return (\n    <div\n      {...rest}\n      ref={(el) => {\n        panel.current = el;\n        if (typeof ref === \"function\") ref(el);\n        else if (ref) ref.current = el;\n      }}\n      className={[\"ui-popc\", className].filter(Boolean).join(\" \")}\n      data-unstyled={unstyled || undefined}\n      role={role}\n      aria-labelledby={shows(title) ? titleId : rest[\"aria-labelledby\"]}\n      aria-describedby={shows(description) ? descId : rest[\"aria-describedby\"]}\n      tabIndex={-1}\n      popover=\"manual\"\n      data-side={side}\n      data-align={align}\n      style={width != null ? { ...style, width, maxWidth: \"calc(100vw - 16px)\" } : style}\n    >\n      {/* React 19 hoists this into <head> once, no matter how many popovers render */}\n      <style href=\"ui-popover-clean\" precedence=\"default\">\n        {css}\n      </style>\n      {hasHead && (\n        <div className=\"ui-popc__head\">\n          {shows(title) && (\n            <p className=\"ui-popc__title\" id={titleId}>\n              {title}\n            </p>\n          )}\n          {shows(description) && (\n            <p className=\"ui-popc__desc\" id={descId}>\n              {description}\n            </p>\n          )}\n        </div>\n      )}\n      {children}\n    </div>\n  );\n}\n\n/* ---------- Helpers ---------- */\n\n/** Text or an element – not true, false, null or an empty string */\nconst shows = (v: ReactNode) => v != null && typeof v !== \"boolean\" && v !== \"\";\n\n/** The first control inside that is shown and reachable by Tab */\nconst firstControl = (root: Element) =>\n  [...root.querySelectorAll<HTMLElement>(\"input, textarea, select, button, [href], [tabindex]\")].find(\n    (el) => el.tabIndex >= 0 && !(el as HTMLButtonElement).disabled && el.getClientRects().length > 0,\n  );\n\n/** The trigger: the anchor itself when it is a control, or its first control that isn't inside the popover */\nfunction triggerOf(anchor: HTMLElement | null, panel: HTMLElement | null) {\n  if (!anchor) return null;\n  if (anchor.matches(\"button, [href], input, [tabindex]\")) return anchor;\n  for (const el of anchor.querySelectorAll<HTMLElement>(\"button, [href], input, [tabindex]\")) if (!panel?.contains(el)) return el;\n  return null;\n}\n\n/** Back to the trigger – unless something inside moved the focus somewhere on purpose */\nconst refocus = (trigger: HTMLElement | null) =>\n  window.setTimeout(() => {\n    const at = document.activeElement;\n    if (trigger?.isConnected && (!at || at === document.body)) trigger.focus({ preventScroll: true });\n  });\n\n/** The top layer, so no overflow cuts it and no z-index fights it */\nfunction showLayer(el: HTMLElement) {\n  if (\"showPopover\" in el && !el.matches(\":popover-open\")) el.showPopover();\n}\nfunction hideLayer(el: HTMLElement) {\n  if (\"hidePopover\" in el && el.isConnected && el.matches(\":popover-open\")) el.hidePopover();\n}\n\n/**\n * Puts the fixed card next to a rectangle: on the preferred side, or the other one when that has more room; the edge\n * it lines up with switches when it would stick out, and it is pushed inside the window as a last resort. Too tall\n * for the room it gets: it scrolls.\n */\nfunction place(el: HTMLElement, r: DOMRect, side: \"top\" | \"bottom\" | \"left\" | \"right\", align: \"start\" | \"center\" | \"end\", gap: number) {\n  const pad = 8;\n  const vw = document.documentElement.clientWidth || window.innerWidth;\n  const vh = window.innerHeight;\n  el.style.maxHeight = \"\";\n  const w = el.offsetWidth;\n  const h = el.offsetHeight;\n  const room = { bottom: vh - r.bottom - gap - pad, top: r.top - gap - pad, right: vw - r.right - gap - pad, left: r.left - gap - pad };\n  const flip = { top: \"bottom\", bottom: \"top\", left: \"right\", right: \"left\" } as const;\n  let s: keyof typeof room = side;\n  let a = align;\n  if (room[s] < (s === \"top\" || s === \"bottom\" ? h : w) && room[flip[s]] > room[s]) s = flip[s];\n  // Beside it but no room either way (a phone): under it instead\n  if ((s === \"left\" || s === \"right\") && room[s] < w) s = room.bottom >= room.top ? \"bottom\" : \"top\";\n  let top: number;\n  let left: number;\n  if (s === \"top\" || s === \"bottom\") {\n    const fit = Math.min(h, Math.max(120, room[s]));\n    top = s === \"bottom\" ? r.bottom + gap : r.top - gap - fit;\n    left = a === \"start\" ? r.left : a === \"end\" ? r.right - w : r.left + r.width / 2 - w / 2;\n    if (a === \"start\" && left + w > vw - pad && r.right - w >= pad) (a = \"end\"), (left = r.right - w);\n    else if (a === \"end\" && left < pad && r.left + w <= vw - pad) (a = \"start\"), (left = r.left);\n    left = Math.min(Math.max(pad, left), vw - pad - w);\n    if (h > fit) el.style.maxHeight = `${fit}px`;\n  } else {\n    left = s === \"right\" ? r.right + gap : r.left - gap - w;\n    top = a === \"start\" ? r.top : a === \"end\" ? r.bottom - h : r.top + r.height / 2 - h / 2;\n    if (a === \"start\" && top + h > vh - pad && r.bottom - h >= pad) (a = \"end\"), (top = r.bottom - h);\n    else if (a === \"end\" && top < pad && r.top + h <= vh - pad) (a = \"start\"), (top = r.top);\n    top = Math.min(Math.max(pad, top), Math.max(pad, vh - pad - h));\n    if (h > vh - 2 * pad) el.style.maxHeight = `${vh - 2 * pad}px`;\n  }\n  el.style.top = `${Math.round(top)}px`;\n  el.style.left = `${Math.round(left)}px`;\n  el.dataset.side = s;\n  el.dataset.align = a;\n}\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n/* Colors and motion as variables – override them on .ui-popc (see Theming in the docs) */\n.ui-popc {\n  --ui-popc-surface: var(--aui-overlay, var(--aui-surface, #ffffff));\n  --ui-popc-strong: var(--aui-ink, #000000);\n  --ui-popc-ink: var(--aui-ink, rgba(0, 0, 0, 0.88));\n  --ui-popc-muted: var(--aui-muted, rgba(0, 0, 0, 0.45));\n  --ui-popc-shadow: var(--aui-shadow-popover, 0 0 0 0.5px var(--aui-border-strong, rgba(0, 0, 0, 0.1)), 0 12px 32px -8px rgba(0, 0, 0, 0.16));\n  --ui-popc-ease: var(--aui-ease, cubic-bezier(0.22, 1, 0.36, 1));\n\n  /* A white card in the top layer: a 0.5px edge and a soft drop */\n  position: fixed;\n  inset: auto;\n  z-index: 1000;\n  display: block;\n  box-sizing: border-box;\n  max-height: calc(100vh - 16px);\n  margin: 0;\n  border: 0;\n  transform-origin: top left;\n  animation: ui-popc-in 220ms var(--ui-popc-ease) both;\n  max-width: calc(100vw - 16px);\n}\n/* Unstyled: the browser's popover look switched off at no weight, so any class of yours wins */\n:where(.ui-popc) { padding: 0; background: none; color: inherit; overflow: visible; }\n/* The card itself – left out with unstyled, so your own classes draw it */\n.ui-popc:not([data-unstyled]) {\n  /* Fits its content up to 360px – a long URL or name wraps instead of stretching it; the width prop lifts the cap */\n  max-width: min(360px, calc(100vw - 16px));\n  padding: 14px;\n  border-radius: calc(var(--aui-radius-lg, 10px) + 2px);\n  background: var(--ui-popc-surface);\n  box-shadow: var(--ui-popc-shadow);\n  color: var(--ui-popc-ink);\n  font-family: var(--aui-font, \"Inter\", \"InterVariable\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  font-size: 13px;\n  line-height: 20px;\n  font-weight: 400;\n  text-align: start;\n  overflow: auto;\n  overscroll-behavior: contain;\n  -webkit-font-smoothing: antialiased;\n}\n.ui-popc::backdrop { display: none; }\n.ui-popc:focus { outline: none; }\n.ui-popc *, .ui-popc *::before, .ui-popc *::after { box-sizing: border-box; }\n\n/* It grows out of the side and edge it is attached to */\n.ui-popc[data-align=\"end\"] { transform-origin: top right; }\n.ui-popc[data-align=\"center\"] { transform-origin: top center; }\n.ui-popc[data-side=\"top\"] { transform-origin: bottom left; animation-name: ui-popc-in-up; }\n.ui-popc[data-side=\"top\"][data-align=\"end\"] { transform-origin: bottom right; }\n.ui-popc[data-side=\"top\"][data-align=\"center\"] { transform-origin: bottom center; }\n.ui-popc[data-side=\"right\"] { transform-origin: top left; animation-name: ui-popc-in-right; }\n.ui-popc[data-side=\"right\"][data-align=\"end\"] { transform-origin: bottom left; }\n.ui-popc[data-side=\"right\"][data-align=\"center\"] { transform-origin: center left; }\n.ui-popc[data-side=\"left\"] { transform-origin: top right; animation-name: ui-popc-in-left; }\n.ui-popc[data-side=\"left\"][data-align=\"end\"] { transform-origin: bottom right; }\n.ui-popc[data-side=\"left\"][data-align=\"center\"] { transform-origin: center right; }\n\n.ui-popc__head { display: grid; gap: 2px; min-width: 0; }\n.ui-popc__head:not(:last-child) { margin-bottom: 12px; }\n.ui-popc__title { margin: 0; font-weight: 500; color: var(--ui-popc-strong); overflow-wrap: anywhere; }\n.ui-popc__desc { margin: 0; font-size: 12px; line-height: 16px; color: var(--ui-popc-muted); overflow-wrap: anywhere; }\n\n@keyframes ui-popc-in { from { opacity: 0; transform: scale(0.96) translateY(-4px); } }\n@keyframes ui-popc-in-up { from { opacity: 0; transform: scale(0.96) translateY(4px); } }\n@keyframes ui-popc-in-right { from { opacity: 0; transform: scale(0.96) translateX(-4px); } }\n@keyframes ui-popc-in-left { from { opacity: 0; transform: scale(0.96) translateX(4px); } }\n\n/* Touch screens: 15px text, so it reads like the rest of a phone UI */\n@media (pointer: coarse) {\n  .ui-popc { padding: 16px; font-size: 15px; line-height: 22px; }\n  .ui-popc__desc { font-size: 13px; line-height: 18px; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ui-popc { animation-duration: 1ms; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-popc {\n  --ui-popc-surface: var(--aui-overlay, var(--aui-surface, #1b1b1e));\n  --ui-popc-strong: var(--aui-ink, #ffffff);\n  --ui-popc-ink: var(--aui-ink, rgba(255, 255, 255, 0.9));\n  --ui-popc-muted: var(--aui-muted, rgba(255, 255, 255, 0.46));\n  --ui-popc-shadow: var(--aui-shadow-popover, 0 0 0 0.5px var(--aui-border-strong, rgba(255, 255, 255, 0.14)), 0 12px 32px -8px rgba(0, 0, 0, 0.6));\n  color-scheme: dark;\n}\n`;\n"}]}