{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"full-screen","type":"registry:ui","title":"FullScreen","description":"Controlled full-screen surface: viewport overlay by default, optional native Fullscreen API, Escape exits with focus and scroll restored.","package":"react-fancy","dependencies":["tailwind-merge","lucide-react"],"registryDependencies":["portal","icon"],"files":[{"path":"components\/fancy\/full-screen\/FullScreen.context.tsx","content":"import { createContext, useContext, type ReactNode } from \"react\";\nimport { PortalContainerContext } from \"..\/portal\/Portal\";\nimport { EscapeLayerContext } from \"..\/lib\/hooks\/use-escape-key\";\nimport type { UseFullScreenResult } from \"..\/lib\/hooks\/use-full-screen\";\n\nexport const FullScreenContext = createContext<UseFullScreenResult | null>(null);\n\n\/** The nearest full-screen surface's state, or `null` outside one. *\/\nexport function useFullScreenContext(): UseFullScreenResult | null {\n  return useContext(FullScreenContext);\n}\n\nexport interface FullScreenProviderProps {\n  \/** What `useFullScreen()` returned for the surface. *\/\n  value: UseFullScreenResult<HTMLElement> | UseFullScreenResult<HTMLDivElement>;\n  children?: ReactNode;\n}\n\n\/**\n * For a surface that owns its root and calls `useFullScreen()` itself (Editor,\n * DeckEditor, SheetWorkbook). Makes `<FullScreen.Toggle \/>` work anywhere inside\n * and redirects descendant `<Portal>`s into the surface while it is active.\n * It also marks everything inside as belonging to this surface for Escape, so\n * an open menu, listbox, popover or tooltip inside closes before the surface\n * exits. A surface that skips the provider loses that, as well as the portals.\n *\/\nexport function FullScreenProvider({ value, children }: FullScreenProviderProps) {\n  return (\n    <FullScreenContext.Provider value={value as UseFullScreenResult}>\n      <EscapeLayerContext.Provider value={value.escapeScope}>\n        <PortalContainerContext.Provider value={value.portalContainer}>{children}<\/PortalContainerContext.Provider>\n      <\/EscapeLayerContext.Provider>\n    <\/FullScreenContext.Provider>\n  );\n}\n","type":"registry:ui","target":"components\/fancy\/full-screen\/FullScreen.context.tsx"},{"path":"components\/fancy\/full-screen\/FullScreen.tsx","content":"import type { HTMLAttributes, ReactNode } from \"react\";\nimport { useFullScreen, type UseFullScreenOptions } from \"..\/lib\/hooks\/use-full-screen\";\nimport { FullScreenProvider } from \".\/FullScreen.context\";\nimport { FullScreenToggle } from \".\/FullScreenToggle\";\n\nexport interface FullScreenProps\n  extends UseFullScreenOptions,\n    Omit<HTMLAttributes<HTMLDivElement>, \"children\"> {\n  children?: ReactNode;\n}\n\n\/**\n * A box that can take over the viewport. Controlled with `fullScreen` +\n * `onFullScreenChange`, or uncontrolled with `defaultFullScreen`; put a\n * `<FullScreen.Toggle \/>` anywhere inside.\n *\n * Give it a background: while active it covers the page, and in `native` mode\n * the browser paints black behind anything transparent.\n *\/\nfunction FullScreenRoot({\n  children,\n  fullScreen,\n  defaultFullScreen,\n  onFullScreenChange,\n  mode,\n  closeOnEscape,\n  lockScroll,\n  zIndex,\n  labels,\n  style,\n  onKeyDown,\n  ...rest\n}: FullScreenProps) {\n  const fs = useFullScreen<HTMLDivElement>({\n    fullScreen,\n    defaultFullScreen,\n    onFullScreenChange,\n    mode,\n    closeOnEscape,\n    lockScroll,\n    zIndex,\n    labels,\n  });\n  const { style: overlayStyle, onKeyDown: fsKeyDown, ...target } = fs.targetProps;\n\n  return (\n    <FullScreenProvider value={fs}>\n      <div\n        data-react-fancy-fullscreen=\"\"\n        {...rest}\n        {...target}\n        style={overlayStyle ? { ...style, ...overlayStyle } : style}\n        onKeyDown={(e) => {\n          onKeyDown?.(e);\n          fsKeyDown(e);\n        }}\n      >\n        {children}\n      <\/div>\n    <\/FullScreenProvider>\n  );\n}\n\nexport const FullScreen = Object.assign(FullScreenRoot, {\n  Toggle: FullScreenToggle,\n});\n","type":"registry:ui","target":"components\/fancy\/full-screen\/FullScreen.tsx"},{"path":"components\/fancy\/full-screen\/FullScreenToggle.tsx","content":"import type { ButtonHTMLAttributes, ReactNode } from \"react\";\nimport { cn } from \"..\/lib\/utils\/cn\";\nimport { Icon } from \"..\/icon\";\nimport { useFullScreenContext } from \".\/FullScreen.context\";\n\nexport interface FullScreenToggleProps\n  extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"type\" | \"onClick\" | \"children\"> {\n  \/** Lucide name shown while NOT full screen. @default \"maximize-2\" *\/\n  enterIcon?: string;\n  \/** Lucide name shown while full screen. @default \"minimize-2\" *\/\n  exitIcon?: string;\n  \/** Replace the icon entirely. Receives the current state. *\/\n  children?: ReactNode | ((fullScreen: boolean) => ReactNode);\n}\n\n\/**\n * The toggle for the nearest `<FullScreen>` \/ `<FullScreenProvider>`. Renders\n * nothing outside one, so a shared toolbar can include it unconditionally.\n *\/\nexport function FullScreenToggle({\n  enterIcon = \"maximize-2\",\n  exitIcon = \"minimize-2\",\n  children,\n  className,\n  ...rest\n}: FullScreenToggleProps) {\n  const fs = useFullScreenContext();\n  if (!fs) return null;\n\n  const content =\n    typeof children === \"function\"\n      ? children(fs.fullScreen)\n      : children ?? <Icon name={fs.fullScreen ? exitIcon : enterIcon} size=\"sm\" \/>;\n\n  return (\n    <button\n      {...rest}\n      {...fs.toggleProps}\n      className={cn(\n        \"inline-flex h-8 w-8 items-center justify-center rounded-md text-sm transition-colors\",\n        \/\/ Its own text colour, like the kit's other icon buttons. Inheriting\n        \/\/ left it black on a dark toolbar whenever the page's colour was dark\n        \/\/ text. Plain utilities, so a consumer's `text-*` replaces them.\n        \"text-[color:var(--fancy-text-muted,var(--color-zinc-500))] hover:text-[color:var(--fancy-text-soft,var(--color-zinc-700))] dark:text-[color:var(--fancy-text-muted-dark,var(--color-zinc-400))] dark:hover:text-[color:var(--fancy-text-soft-dark,var(--color-zinc-300))]\",\n        \"hover:bg-[color:var(--fancy-surface-muted,var(--color-zinc-100))] dark:hover:bg-[color:var(--fancy-surface-muted-dark,var(--color-zinc-800))]\",\n        \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-brand,var(--color-blue-500))]\",\n        className,\n      )}\n    >\n      {content}\n    <\/button>\n  );\n}\n\nFullScreenToggle.displayName = \"FullScreenToggle\";\n","type":"registry:ui","target":"components\/fancy\/full-screen\/FullScreenToggle.tsx"},{"path":"components\/fancy\/full-screen\/index.ts","content":"export { FullScreen } from \".\/FullScreen\";\nexport type { FullScreenProps } from \".\/FullScreen\";\nexport { FullScreenToggle } from \".\/FullScreenToggle\";\nexport type { FullScreenToggleProps } from \".\/FullScreenToggle\";\nexport { FullScreenContext, FullScreenProvider, useFullScreenContext } from \".\/FullScreen.context\";\nexport type { FullScreenProviderProps } from \".\/FullScreen.context\";\nexport { useFullScreen } from \"..\/lib\/hooks\/use-full-screen\";\nexport type {\n  FullScreenMode,\n  FullScreenLayer,\n  UseFullScreenOptions,\n  UseFullScreenResult,\n  FullScreenTargetProps,\n  FullScreenToggleProps as FullScreenToggleButtonProps,\n} from \"..\/lib\/hooks\/use-full-screen\";\n","type":"registry:ui","target":"components\/fancy\/full-screen\/index.ts"},{"path":"components\/fancy\/lib\/hooks\/use-escape-key.ts","content":"import { createContext, useContext, useEffect, useRef } from \"react\";\n\n\/**\n * Escape belongs to the innermost open layer.\n *\n * Every `useEscapeKey(handler, enabled)` call is a LAYER while `enabled`: a\n * Dropdown's menu, a Select's listbox, a Popover, a Modal, a Drawer, a Tooltip,\n * a full-screen surface. They all join one stack, one document listener reads\n * each Escape, and only one layer gets it.\n *\n * Why not one document listener per layer, as this hook used to do: every\n * open layer then received the same Escape. A Dropdown inside a Modal closed\n * the menu AND the dialog. Inside a full-screen surface it was worse: the\n * surface's own key handler runs during the event's trip through the surface,\n * before any document listener, so it could not tell that a menu wanted the\n * key. DeckEditor exited full screen with its Shape menu still open. Fixing\n * that by ordering listeners would only last until someone registers one in a\n * different place. So the decision is made from the stack instead.\n *\n * \"Innermost\" is decided in two steps:\n *\n * 1. The most recently opened layer is the candidate. Opening a menu from a\n *    dialog is the normal order of things.\n * 2. A full-screen surface knows its own descendants: layers rendered inside\n *    it register under it through `EscapeLayerContext`, and that context flows\n *    through portals. If the candidate is a surface with open layers inside,\n *    the most recent of those wins instead. Without this step, a menu opened\n *    BEFORE an agent put its surface full screen would lose the Escape to the\n *    surface.\n *\n * An Escape that something already handled (`event.defaultPrevented`, for\n * example a text field clearing itself) is left alone.\n *\/\n\ninterface LayerEntry {\n  handler: { current: () => void };\n  \/** The surface this layer was rendered inside, if any. *\/\n  parent: EscapeScope | null;\n  \/** Set when the layer IS a surface: layers inside it point here. *\/\n  scope: EscapeScope | null;\n}\n\n\/** Identity of a full-screen surface for the layers rendered inside it. *\/\nexport type EscapeScope = object;\n\nconst stack: LayerEntry[] = [];\n\n\/** The surface (if any) that layers rendered here belong to. *\/\nexport const EscapeLayerContext = createContext<EscapeScope | null>(null);\n\nfunction innermost(): LayerEntry | undefined {\n  let candidate = stack[stack.length - 1];\n  while (candidate?.scope) {\n    const scope = candidate.scope;\n    let inner: LayerEntry | undefined;\n    for (let i = stack.length - 1; i >= 0; i--) {\n      if (stack[i].parent === scope) {\n        inner = stack[i];\n        break;\n      }\n    }\n    if (!inner) break;\n    candidate = inner;\n  }\n  return candidate;\n}\n\nfunction onDocumentKeyDown(event: KeyboardEvent) {\n  if (event.key !== \"Escape\" || event.defaultPrevented) return;\n  const target = innermost();\n  if (!target) return;\n  event.preventDefault();\n  target.handler.current();\n}\n\nfunction push(entry: LayerEntry): () => void {\n  if (stack.length === 0) document.addEventListener(\"keydown\", onDocumentKeyDown);\n  stack.push(entry);\n  return () => {\n    const i = stack.indexOf(entry);\n    if (i >= 0) stack.splice(i, 1);\n    if (stack.length === 0) document.removeEventListener(\"keydown\", onDocumentKeyDown);\n  };\n}\n\n\/**\n * Register a layer that closes on Escape while `enabled`. Pass `scope` only for\n * a surface that other layers can open inside (a full-screen surface).\n *\n * Returns `isInnermost()`, for a layer that also handles Escape in its own key\n * handler and must not act when a layer above it owns the key.\n *\n * @internal Exported for full-screen surfaces in sibling packages; ordinary\n * layers call `useEscapeKey`.\n *\/\nexport function useEscapeLayer(\n  handler: () => void,\n  enabled: boolean,\n  scope: EscapeScope | null = null,\n): { isInnermost: () => boolean } {\n  const parent = useContext(EscapeLayerContext);\n  const handlerRef = useRef(handler);\n  handlerRef.current = handler;\n  const entryRef = useRef<LayerEntry | null>(null);\n\n  \/\/ Depends on `enabled` (and identity), NOT on `handler`: callers pass inline\n  \/\/ callbacks, and re-registering on every render would move a dialog above\n  \/\/ the menu it had just opened.\n  useEffect(() => {\n    if (!enabled) return;\n    const entry: LayerEntry = { handler: handlerRef, parent, scope };\n    entryRef.current = entry;\n    const pop = push(entry);\n    return () => {\n      pop();\n      if (entryRef.current === entry) entryRef.current = null;\n    };\n  }, [enabled, parent, scope]);\n\n  const isInnermostRef = useRef(() => entryRef.current !== null && innermost() === entryRef.current);\n  return { isInnermost: isInnermostRef.current };\n}\n\nexport function useEscapeKey(handler: () => void, enabled: boolean = true): void {\n  useEscapeLayer(handler, enabled);\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/hooks\/use-escape-key.ts"},{"path":"components\/fancy\/lib\/hooks\/use-full-screen.ts","content":"import {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type RefObject,\n} from \"react\";\nimport { useControllableState } from \".\/use-controllable-state\";\nimport { lockBodyScroll } from \"..\/utils\/scroll-lock\";\nimport { useEscapeLayer, type EscapeScope } from \".\/use-escape-key\";\n\n\/**\n * How a surface goes full screen.\n *\n * - `\"overlay\"` (default): the element itself becomes `position: fixed` over the\n *   viewport, in place. It always works: inside an iframe without\n *   `allow=\"fullscreen\"`, inside a dialog, without a user gesture. Nothing is\n *   reparented, so an editor keeps its caret, undo stack and DOM.\n * - `\"native\"`: the browser Fullscreen API (`element.requestFullscreen()`),\n *   which hides the browser chrome too. The overlay is applied first and stays\n *   if the request is rejected or the API is missing, so the user gets full\n *   screen either way; `activeMode` reports which one took.\n *\/\nexport type FullScreenMode = \"overlay\" | \"native\";\n\nexport interface UseFullScreenOptions {\n  \/** Controlled flag. Pair with `onFullScreenChange`. *\/\n  fullScreen?: boolean;\n  \/** Initial flag when uncontrolled. @default false *\/\n  defaultFullScreen?: boolean;\n  \/**\n   * Fired with the requested state \u2014 from the toggle, Escape, an agent calling\n   * `setFullScreen`, or the user leaving the browser's own full screen.\n   *\/\n  onFullScreenChange?: (fullScreen: boolean) => void;\n  \/** @default \"overlay\" *\/\n  mode?: FullScreenMode;\n  \/** Leave full screen on Escape. @default true *\/\n  closeOnEscape?: boolean;\n  \/** Stop the page behind from scrolling while active. @default true *\/\n  lockScroll?: boolean;\n  \/** Stacking order of the overlay. Matches `Modal`. @default 50 *\/\n  zIndex?: number;\n  \/** Accessible names for the toggle. *\/\n  labels?: { enter?: string; exit?: string };\n}\n\nexport interface FullScreenTargetProps<T extends HTMLElement> {\n  ref: RefObject<T | null>;\n  \"data-fullscreen\": \"true\" | \"false\";\n  \"data-fullscreen-mode\"?: FullScreenMode;\n  \"data-fullscreen-layer\"?: FullScreenLayer;\n  style?: CSSProperties;\n  onKeyDown: (event: ReactKeyboardEvent<T>) => void;\n}\n\nexport interface FullScreenToggleProps {\n  type: \"button\";\n  onClick: () => void;\n  \"aria-pressed\": boolean;\n  \"aria-label\": string;\n  title: string;\n  \"data-react-fancy-fullscreen-toggle\": \"\";\n  \"data-fullscreen\": \"true\" | \"false\";\n}\n\nexport interface UseFullScreenResult<T extends HTMLElement = HTMLElement> {\n  fullScreen: boolean;\n  setFullScreen: (fullScreen: boolean) => void;\n  toggle: () => void;\n  \/** The mechanism in effect while active; `null` when not full screen. *\/\n  activeMode: FullScreenMode | null;\n  \/**\n   * `\"top\"` while the surface is in the browser's top layer (a manual popover,\n   * or the native full screen), `\"fixed\"` while it relies on `position: fixed`\n   * (no popover API, or the browser refused). `null` when not full screen.\n   *\/\n  layer: FullScreenLayer | null;\n  \/** Attach to the element that goes full screen. Also inside `targetProps`. *\/\n  ref: RefObject<T | null>;\n  \/**\n   * Spread onto the surface's root. Carries the ref, the `data-fullscreen`\n   * handle, the overlay style and the Escape\/Tab handler. Merge your own\n   * `style` \/ `onKeyDown` if the root has them.\n   *\/\n  targetProps: FullScreenTargetProps<T>;\n  \/** Spread onto a `<button>` to make it the toggle. *\/\n  toggleProps: FullScreenToggleProps;\n  \/**\n   * The element to portal into while active, else `null`. Provide it through\n   * `PortalContainerContext` so menus opened inside stay visible.\n   *\/\n  portalContainer: HTMLElement | null;\n  \/**\n   * Identity of this surface for Escape routing. Provide it through\n   * `EscapeLayerContext` (FullScreenProvider does) so a menu opened inside\n   * gets Escape before the surface does.\n   *\/\n  escapeScope: EscapeScope;\n}\n\nconst FOCUSABLE = [\n  \"a[href]\",\n  \"button:not([disabled])\",\n  \"input:not([disabled])\",\n  \"textarea:not([disabled])\",\n  \"select:not([disabled])\",\n  \"[contenteditable]:not([contenteditable='false'])\",\n  \"[tabindex]:not([tabindex='-1'])\",\n].join(\", \");\n\n\nconst OVERLAY_STYLE: CSSProperties = {\n  position: \"fixed\",\n  top: 0,\n  right: 0,\n  bottom: 0,\n  left: 0,\n  width: \"100%\",\n  height: \"100%\",\n  maxWidth: \"none\",\n  maxHeight: \"none\",\n  margin: 0,\n  borderRadius: 0,\n  boxSizing: \"border-box\",\n};\n\n\/**\n * Lift `el` into the browser's top layer with `popover=\"manual\"`.\n *\n * Why: `position: fixed` is only fixed to the viewport while no ancestor has a\n * transform, filter, perspective or paint containment. With one, the overlay\n * is fixed to THAT box. In the showcase a surface in a Tabs panel came out\n * 1248x95, because `.fancy-fade-in` held `transform: matrix(1,0,0,1,0,0)` via\n * `animation-fill-mode: both`. Transform-centred dialogs do the same. A\n * top-layer element's containing block is the viewport whatever its ancestors\n * do, and the popover API gets it there without moving it in the DOM.\n *\n * `manual` because `auto` light-dismisses on an outside click and on Escape;\n * Escape stays with the surface's own handler.\n *\n * Returns a release function, or `null` where the API is missing or refuses\n * (the caller keeps the fixed overlay). `onLost` fires if something else\n * closes the popover: a `[popover]` that is not open is `display: none`, so the\n * attribute must go at once or the surface vanishes while still \"full screen\".\n *\/\n\/** Surfaces currently in the top layer through a popover, with a \"re-show\" each. *\/\nconst promoted = new Map<HTMLElement, () => void>();\n\nfunction promoteToTopLayer(el: HTMLElement, onLost: () => void): (() => void) | null {\n  const p = el as HTMLElement & { showPopover?: () => void; hidePopover?: () => void };\n  if (typeof p.showPopover !== \"function\" || typeof p.hidePopover !== \"function\") return null;\n  \/\/ The consumer made it a popover themselves; do not fight over it.\n  if (el.hasAttribute(\"popover\")) return null;\n\n  el.setAttribute(\"popover\", \"manual\");\n  try {\n    p.showPopover();\n  } catch {\n    el.removeAttribute(\"popover\");\n    return null;\n  }\n\n  let open = true;\n  let raising = false;\n  const onToggle = (e: Event) => {\n    if (raising || (e as Event & { newState?: string }).newState !== \"closed\" || !open) return;\n    open = false;\n    promoted.delete(el);\n    el.removeEventListener(\"toggle\", onToggle);\n    el.removeAttribute(\"popover\");\n    onLost();\n  };\n  el.addEventListener(\"toggle\", onToggle);\n\n  \/\/ The top layer stacks in SHOW order. React runs a child's layout effects\n  \/\/ before its parent's, so a surface nested in another that goes full screen\n  \/\/ in the same commit is shown first and then buried by its parent. Re-show\n  \/\/ any promoted descendant so the innermost stays on top.\n  for (const [other, raise] of promoted) {\n    if (other !== el && el.contains(other)) raise();\n  }\n  promoted.set(el, () => {\n    raising = true;\n    try {\n      p.hidePopover!();\n      p.showPopover!();\n    } catch {\n      \/* leave it where it is *\/\n    } finally {\n      raising = false;\n    }\n  });\n\n  return () => {\n    promoted.delete(el);\n    el.removeEventListener(\"toggle\", onToggle);\n    if (open) {\n      open = false;\n      try {\n        p.hidePopover!();\n      } catch {\n        \/* already hidden, e.g. removed from the document *\/\n      }\n    }\n    el.removeAttribute(\"popover\");\n  };\n}\n\n\/** Which mechanism keeps the surface over the page. *\/\nexport type FullScreenLayer = \"top\" | \"fixed\";\n\ninterface ScrollMemo {\n  el: Element;\n  top: number;\n  left: number;\n}\n\n\/**\n * A controlled full-screen state for any surface: Editor today, DeckEditor and\n * SheetWorkbook next. Render-time output is pure (no `window`\/`document`), so it\n * server-renders; everything that touches the DOM runs in effects.\n *\/\nexport function useFullScreen<T extends HTMLElement = HTMLElement>(\n  options: UseFullScreenOptions = {},\n): UseFullScreenResult<T> {\n  const {\n    fullScreen: controlled,\n    defaultFullScreen = false,\n    onFullScreenChange,\n    mode = \"overlay\",\n    closeOnEscape = true,\n    lockScroll = true,\n    zIndex = 50,\n    labels,\n  } = options;\n\n  const [fullScreen, setState] = useControllableState(controlled, defaultFullScreen, onFullScreenChange);\n  const ref = useRef<T | null>(null);\n  const token = useRef({});\n  const [nativeActive, setNativeActive] = useState(false);\n  const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);\n  \/\/ Whether the overlay STYLE is on. It trails `fullScreen` by one synchronous\n  \/\/ commit on entry, so the layout effect below reads scroll offsets while the\n  \/\/ surface is still in flow. Read after the style lands and the browser has\n  \/\/ already clamped every scroll container around it: a panel at 569 read as\n  \/\/ 342 in Chromium, and that is where the user was sent back to. The re-render\n  \/\/ happens inside the layout phase, before paint, so nothing flashes.\n  \/\/ Initialised from `fullScreen` so a surface that starts full screen renders\n  \/\/ the overlay on the server and on hydration alike.\n  const [applied, setApplied] = useState(fullScreen);\n  \/\/ \"fixed\" until a layout effect has actually promoted the surface, so the\n  \/\/ server and the hydrating client agree, and a refused promotion reads true.\n  const [layer, setLayer] = useState<FullScreenLayer>(\"fixed\");\n  const topRelease = useRef<(() => void) | null>(null);\n\n  const promote = (el: HTMLElement) => {\n    if (topRelease.current) return;\n    topRelease.current = promoteToTopLayer(el, () => {\n      topRelease.current = null;\n      setLayer(\"fixed\");\n    });\n    setLayer(topRelease.current ? \"top\" : \"fixed\");\n  };\n  const demote = () => {\n    topRelease.current?.();\n    topRelease.current = null;\n  };\n\n  const current = useRef(fullScreen);\n  current.current = fullScreen;\n\n  \/\/ Only report real transitions: Escape on an inactive surface, or a second\n  \/\/ click racing a re-render, must not fire `onFullScreenChange(false)` twice.\n  const setFullScreen = useCallback(\n    (next: boolean) => {\n      if (current.current === next) return;\n      current.current = next;\n      setState(next);\n    },\n    [setState],\n  );\n  const toggle = useCallback(() => setFullScreen(!current.current), [setFullScreen]);\n\n  \/\/ Enter \/ exit bookkeeping. A LAYOUT effect so the cleanup runs after the\n  \/\/ overlay style has been removed from the DOM but before paint: scroll offsets\n  \/\/ restored there never flash, and the surface is back in flow to measure.\n  useLayoutEffect(() => {\n    if (!fullScreen) return;\n    const el = ref.current;\n    if (!el) return;\n\n    \/\/ Taking the surface out of flow shrinks every scroll container around it,\n    \/\/ which clamps their scrollTop. Remember them so exit lands where it began.\n    const scrolled: ScrollMemo[] = [];\n    for (let p = el.parentElement; p && p !== document.body && p !== document.documentElement; p = p.parentElement) {\n      if (p.scrollTop || p.scrollLeft) scrolled.push({ el: p, top: p.scrollTop, left: p.scrollLeft });\n    }\n    const winX = window.scrollX;\n    const winY = window.scrollY;\n\n    const release = lockScroll ? lockBodyScroll() : null;\n    setPortalContainer(el);\n    setApplied(true);\n\n    \/\/ Native mode asks the browser first: Chromium rejects requestFullscreen()\n    \/\/ on an element that is already an open popover (\"already open as a\n    \/\/ Popover, and therefore cannot be opened via the fullscreen API\"). The\n    \/\/ native effect promotes on refusal instead.\n    const native = mode === \"native\" && typeof el.requestFullscreen === \"function\" && document.fullscreenEnabled !== false;\n    if (!native) promote(el);\n\n    return () => {\n      \/\/ Out of the top layer first, so the surface is back in flow before the\n      \/\/ scroll offsets are put back.\n      demote();\n      setLayer(\"fixed\");\n      release?.();\n      for (const m of scrolled) {\n        if (m.el.scrollTop !== m.top) m.el.scrollTop = m.top;\n        if (m.el.scrollLeft !== m.left) m.el.scrollLeft = m.left;\n      }\n      if (window.scrollX !== winX || window.scrollY !== winY) window.scrollTo(winX, winY);\n      setPortalContainer(null);\n      setApplied(false);\n    };\n    \/\/ eslint-disable-next-line react-hooks\/exhaustive-deps\n  }, [fullScreen, lockScroll, mode]);\n\n  \/\/ Focus in and back out. A PASSIVE effect on purpose: React re-focuses\n  \/\/ whatever had focus before a commit once the commit's mutations finish, so a\n  \/\/ focus() issued from a layout-effect cleanup is silently undone.\n  useEffect(() => {\n    if (!fullScreen) return;\n    const el = ref.current;\n    if (!el) return;\n\n    const opener =\n      document.activeElement instanceof HTMLElement && document.activeElement !== document.body\n        ? document.activeElement\n        : null;\n\n    \/\/ Focus must not stay behind the overlay, where the user cannot see it.\n    if (!el.contains(document.activeElement)) {\n      el.querySelector<HTMLElement>(FOCUSABLE)?.focus({ preventScroll: true });\n    }\n\n    return () => {\n      \/\/ Give focus back only if it is still ours to give: inside the surface,\n      \/\/ or lost to <body>. A user who clicked elsewhere keeps their choice.\n      const active = document.activeElement;\n      if (opener?.isConnected && (!active || active === document.body || el.contains(active))) {\n        opener.focus({ preventScroll: true });\n      }\n    };\n  }, [fullScreen]);\n\n  \/\/ The browser Fullscreen API, on top of the overlay.\n  useEffect(() => {\n    if (!fullScreen || mode !== \"native\") return;\n    const el = ref.current;\n    if (!el || typeof el.requestFullscreen !== \"function\" || document.fullscreenEnabled === false) return;\n\n    let cancelled = false;\n    let entered = false;\n\n    const onChange = () => {\n      if (document.fullscreenElement === el) {\n        entered = true;\n        setNativeActive(true);\n        \/\/ The browser's full screen is the top layer too.\n        setLayer(\"top\");\n      } else if (entered) {\n        \/\/ The user left through the browser's own UI (Esc, F11, the close\n        \/\/ pill). Keep the controlled state honest rather than leaving an\n        \/\/ overlay up that they believe they dismissed.\n        entered = false;\n        setNativeActive(false);\n        setLayer(\"fixed\");\n        if (!cancelled) setFullScreen(false);\n      }\n    };\n    document.addEventListener(\"fullscreenchange\", onChange);\n\n    \/\/ Blocked requests (iframe without allow=\"fullscreen\", no user gesture, a\n    \/\/ permissions policy) reject, and some engines throw instead. Either way\n    \/\/ the user stays full screen in the overlay, lifted into the top layer.\n    const refused = () => {\n      if (!cancelled && current.current && ref.current === el) promote(el);\n    };\n    try {\n      Promise.resolve(el.requestFullscreen()).then(onChange, refused);\n    } catch {\n      refused();\n    }\n\n    return () => {\n      cancelled = true;\n      document.removeEventListener(\"fullscreenchange\", onChange);\n      if (document.fullscreenElement === el) {\n        try {\n          Promise.resolve(document.exitFullscreen()).catch(() => {});\n        } catch {\n          \/* already left *\/\n        }\n      }\n      setNativeActive(false);\n    };\n  }, [fullScreen, mode, setFullScreen]);\n\n  \/\/ The surface is an Escape LAYER while active, in the same stack as every\n  \/\/ Dropdown, Select, Popover, Modal and Tooltip. Layers opened inside it\n  \/\/ register under `token.current` (through EscapeLayerContext, which crosses\n  \/\/ portals), so the stack knows they are inside and hands them Escape first,\n  \/\/ whatever order the listeners run in. With nothing focused, or focus\n  \/\/ outside the surface, the stack's document listener delivers it here.\n  const escapeLayer = useEscapeLayer(() => setFullScreen(false), fullScreen && closeOnEscape, token.current);\n\n  const onKeyDown = useCallback(\n    (e: ReactKeyboardEvent<T>) => {\n      if (!current.current || e.defaultPrevented) return;\n      const el = ref.current;\n      if (!el) return;\n\n      if (e.key === \"Tab\") {\n        \/\/ Keep Tab inside the surface; what is behind the overlay is invisible.\n        const items = Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE));\n        if (items.length === 0) return;\n        const first = items[0];\n        const last = items[items.length - 1];\n        const active = document.activeElement;\n        if (e.shiftKey && (active === first || active === el)) {\n          e.preventDefault();\n          last.focus();\n        } else if (!e.shiftKey && active === last) {\n          e.preventDefault();\n          first.focus();\n        }\n        return;\n      }\n\n      if (e.key !== \"Escape\" || !closeOnEscape) return;\n      \/\/ A layer opened from inside this surface is open (a menu, a listbox, a\n      \/\/ popover, a tooltip, a dialog): it owns this Escape. Let the event go on\n      \/\/ to the stack's document listener, which closes that layer only. This\n      \/\/ is a lookup, not a race: in 5.61.1 the surface asked\n      \/\/ `defaultPrevented`, which a document-level menu had not set yet.\n      if (!escapeLayer.isInnermost()) return;\n      \/\/ Portal content that never registered as a layer (a consumer's own\n      \/\/ menu in a react-fancy Portal) still gets the benefit of the doubt.\n      \/\/ React bubbles portal events through the component tree, so check the\n      \/\/ DOM: a portal wrapper that does not contain this surface is inside it.\n      const layer = (e.target as Element).closest?.(\"[data-react-fancy-portal]\");\n      if (layer && !layer.contains(el)) return;\n      \/\/ Stop it here so an enclosing dialog's document listener does not also\n      \/\/ close: one Escape, one step back.\n      e.preventDefault();\n      e.stopPropagation();\n      setFullScreen(false);\n    },\n    \/\/ eslint-disable-next-line react-hooks\/exhaustive-deps\n    [closeOnEscape, setFullScreen],\n  );\n\n  const activeMode: FullScreenMode | null = fullScreen ? (nativeActive ? \"native\" : \"overlay\") : null;\n  const enterLabel = labels?.enter ?? \"Enter full screen\";\n  const exitLabel = labels?.exit ?? \"Exit full screen\";\n\n  return {\n    fullScreen,\n    setFullScreen,\n    toggle,\n    activeMode,\n    layer: activeMode ? layer : null,\n    ref,\n    portalContainer,\n    escapeScope: token.current,\n    targetProps: {\n      ref,\n      \"data-fullscreen\": fullScreen ? \"true\" : \"false\",\n      ...(activeMode ? { \"data-fullscreen-mode\": activeMode, \"data-fullscreen-layer\": layer } : {}),\n      ...(fullScreen && applied ? { style: { ...OVERLAY_STYLE, zIndex } } : {}),\n      onKeyDown,\n    },\n    toggleProps: {\n      type: \"button\",\n      onClick: toggle,\n      \"aria-pressed\": fullScreen,\n      \"aria-label\": fullScreen ? exitLabel : enterLabel,\n      title: fullScreen ? exitLabel : enterLabel,\n      \"data-react-fancy-fullscreen-toggle\": \"\",\n      \"data-fullscreen\": fullScreen ? \"true\" : \"false\",\n    },\n  };\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/hooks\/use-full-screen.ts"},{"path":"components\/fancy\/lib\/utils\/cn.ts","content":"import { twMerge } from \"tailwind-merge\";\n\n\/**\n * A class value: a string, a number, a conditional map, an array of any of\n * those, or nothing. Mirrors what `clsx` accepted, because every component in\n * this package already passes these shapes.\n *\/\nexport type ClassValue =\n  | string\n  | number\n  | bigint\n  | null\n  | undefined\n  | false\n  | ClassDictionary\n  | ClassValue[];\n\ntype ClassDictionary = Record<string, unknown>;\n\n\/**\n * Flatten class values into a space-separated string.\n *\n * This was `clsx` until 2026-08-19. It is roughly twenty lines of logic, it\n * shipped as a RUNTIME dependency to every consumer of this package, and its\n * last commit was 801 days old \u2014 so it fell to the rule that third-party code\n * must be both approved and actively maintained. Owning it costs less than\n * carrying it.\n *\n * `tailwind-merge` deliberately stays: deciding which of two conflicting\n * Tailwind utilities wins requires modelling the entire utility space, which is\n * emphatically not twenty lines, and it is actively maintained.\n *\n * Falsy values are dropped \u2014 including `0` and `\"\"`, which is what `clsx` did\n * and what `tests\/cn.test.ts` pins. A truthy number is kept, because digits are\n * legal class names. `bigint` is admitted because clsx's signature did and\n * the compiler proved it reachable from two call sites here.\n *\/\nfunction flatten(value: ClassValue): string {\n  if (!value) return \"\";\n\n  if (typeof value === \"string\" || typeof value === \"number\" || typeof value === \"bigint\") {\n    return String(value);\n  }\n\n  if (Array.isArray(value)) {\n    let out = \"\";\n    for (const item of value) {\n      const part = flatten(item);\n      if (part) out = out ? `${out} ${part}` : part;\n    }\n    return out;\n  }\n\n  let out = \"\";\n  for (const key in value) {\n    if (value[key]) out = out ? `${out} ${key}` : key;\n  }\n  return out;\n}\n\nexport function cn(...inputs: ClassValue[]): string {\n  return twMerge(flatten(inputs));\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/utils\/cn.ts"},{"path":"components\/fancy\/lib\/hooks\/use-controllable-state.ts","content":"import { useCallback, useRef, useState } from \"react\";\n\n\/**\n * One state slot that works controlled or uncontrolled.\n *\n * ---------------------------------------------------------------------------\n * Why the value is tracked in a ref (fixed 5.32.0)\n * ---------------------------------------------------------------------------\n *\n * This resolved a functional update against the RENDER CLOSURE's `value` and then\n * handed React an already-computed value:\n *\n *     const nextValue = typeof next === \"function\" ? next(value) : next;\n *     setUncontrolledValue(nextValue);\n *\n * So `setValue(fn)` twice in one batch had both calls compute from the same stale\n * `value`, and **the first update was silently lost.** React's own `setState`\n * updater form does not have that problem precisely because React resolves it\n * against the latest state; passing a pre-computed value gives that up.\n *\n * Found by a hook test that paste-held two items inside one `act()` and got one.\n * It is not an exotic case \u2014 any caller that loops, or handles two events in a\n * batch, hits it, and the symptom is a dropped update with nothing to indicate one\n * happened.\n *\n * The fix cannot be \"pass the updater to React\", because `onChange` has to be\n * called with the resulting value and an updater must stay pure \u2014 React may invoke\n * it twice (StrictMode), which would fire `onChange` twice. So the previous value\n * comes from a ref that is re-synced from the authoritative `value` on every render\n * and advanced on every set. Within a batch the ref carries intent; after a render\n * the real value wins.\n *\n * A side effect worth having: `setValue` no longer depends on `value`, so it is\n * stable for the life of the component.\n *\/\nexport function useControllableState<T>(\n  controlledValue: T | undefined,\n  defaultValue: T,\n  onChange?: (value: T) => void,\n): [T, (value: T | ((prev: T) => T)) => void] {\n  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);\n  const isControlled = controlledValue !== undefined;\n  const value = isControlled ? controlledValue : uncontrolledValue;\n\n  const onChangeRef = useRef(onChange);\n  onChangeRef.current = onChange;\n\n  \/\/ Re-synced every render from whichever side owns the value. A controlled parent\n  \/\/ that ignores `onChange` therefore wins on the next render rather than being\n  \/\/ overridden by what this hook optimistically recorded.\n  const valueRef = useRef(value);\n  valueRef.current = value;\n\n  const isControlledRef = useRef(isControlled);\n  isControlledRef.current = isControlled;\n\n  const setValue = useCallback((next: T | ((prev: T) => T)) => {\n    const nextValue =\n      typeof next === \"function\"\n        ? (next as (prev: T) => T)(valueRef.current)\n        : next;\n\n    \/\/ Advance the ref BEFORE notifying, so a second call in the same batch builds\n    \/\/ on this one instead of on what the last render saw.\n    valueRef.current = nextValue;\n\n    if (!isControlledRef.current) {\n      setUncontrolledValue(nextValue);\n    }\n\n    onChangeRef.current?.(nextValue);\n  }, []);\n\n  return [value, setValue];\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/hooks\/use-controllable-state.ts"},{"path":"components\/fancy\/lib\/utils\/scroll-lock.ts","content":"\/**\n * Reference-counted body scroll lock.\n *\n * Two surfaces locking independently with the save-and-restore pattern\n * (`const original = body.style.overflow`) only unwind correctly in strict LIFO\n * order. Exit them out of order and the first one out restores `\"\"` while the\n * second is still covering the viewport, so the page scrolls behind it. Counting\n * holders keeps the lock until the last one lets go, and restores the value that\n * was there before the FIRST took it.\n *\n * Call only from an effect: it touches `document`.\n *\/\nlet holders = 0;\nlet saved = \"\";\n\nexport function lockBodyScroll(): () => void {\n  if (holders === 0) {\n    saved = document.body.style.overflow;\n    document.body.style.overflow = \"hidden\";\n  }\n  holders++;\n\n  let released = false;\n  return () => {\n    if (released) return;\n    released = true;\n    holders--;\n    if (holders === 0) document.body.style.overflow = saved;\n  };\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/utils\/scroll-lock.ts"}]}