{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fan",
  "type": "registry:ui",
  "title": "Fan",
  "description": "A compact control bar whose cells open in place: a label, the current choice that fans its siblings out along one axis, and an options cap that stretches sideways into more controls. One open at a time; nothing hides in a menu.",
  "dependencies": [],
  "registryDependencies": [
    "https://metalui.dev/r/tokens.json"
  ],
  "files": [
    {
      "path": "packages/metalui/src/components/fan/fan.tsx",
      "type": "registry:ui",
      "target": "components/metalui/fan/fan.tsx",
      "content": "'use client';\n\nimport * as React from 'react';\nimport { Toolbar as BaseToolbar } from '@base-ui/react/toolbar';\n\n/* ─────────────────────────────────────────────────────────\n * FAN: a compact control bar whose cells open in place\n *\n *   rest      a row of graphite caps: a label (what the bar is about), the current choice, and an\n *             options cap; nothing else is on screen\n *   picker    pressing the current choice fans its siblings out along one axis from behind it\n *             (up, for a bar at the bottom of the screen; both ways, centred on it, elsewhere),\n *             each on the part spring, staggered by a beat; choosing one folds the fan and the\n *             choice takes the cap\n *   tray      pressing the options cap stretches it sideways into a capsule of more controls, on\n *             the part spring; the controls fade in as it opens; ‹ at its end folds it back\n *   one open  opening one cell folds any other; Escape or a press outside folds it and focus\n *             returns to the cell that opened it\n *   keys      Enter or Space opens; arrows move along the fan; Enter picks; Escape folds\n *   hover     caps brighten on settle (the icon-button recipe); a pressed cap sinks 1\n *   reduced   the fan and the tray appear and fold without travel (a crossfade)\n * Every option stays one press away and in view once opened: nothing hides in a menu.\n * ───────────────────────────────────────────────────────── */\n\ntype Open = { id: string; restore: () => void } | null;\nconst FanContext = React.createContext<{ open: Open; setOpen: (o: Open) => void } | null>(null);\nconst useFan = () => {\n  const c = React.useContext(FanContext);\n  if (!c) throw new Error('Fan cells go inside <Fan>');\n  return c;\n};\n\nconst reduced = () => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n/* Styled with the theme's utilities: the icon-button tool cap for cells, the toolbar recipe for the tray. */\nconst ROW = 'mu-fan relative inline-flex items-end gap-toolbar-gap';\nconst CAP = 'mu-icon-trigger box-border inline-grid place-items-center flex-none p-0 border-0 cursor-pointer tap-highlight-none size-icon-button-tool-size rounded-icon-button-tool-radius text-icon-button-tool-ink recipe-icon-button-tool transition-icon-button-tool [&>svg]:size-icon-button-tool-glyph active:translate-y-icon-button-tool-press active:recipe-icon-button-tool-pressed focus-visible:focus-ring-flush';\nconst LABEL = 'mu-fan-label box-border inline-flex items-center h-icon-button-tool-size px-toolbar-pad rounded-icon-button-tool-radius recipe-icon-button-tool type-toolbar-search text-icon-button-tool-ink whitespace-nowrap';\nconst TRAY = 'mu-fan-tray relative box-border inline-flex items-center h-icon-button-tool-size overflow-hidden rounded-icon-button-tool-radius recipe-icon-button-tool';\n/** The part spring, from the theme (duration and curve). */\nconst SPRING = 'duration-part ease-part';\n\nexport interface FanProps {\n  'aria-label': string;\n  className?: string;\n  children: React.ReactNode;\n}\n\n/** The bar. It keeps which cell is open (one at a time) and folds it on Escape or a press outside. */\nexport function Fan({ className, children, ...props }: FanProps) {\n  const [open, setOpenState] = React.useState<Open>(null);\n  const root = React.useRef<HTMLDivElement>(null);\n  const setOpen = React.useCallback((o: Open) => setOpenState(o), []);\n  React.useEffect(() => {\n    if (!open) return;\n    const fold = () => { const o = open; setOpenState(null); o.restore(); };\n    const key = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); fold(); } };\n    const press = (e: PointerEvent) => {\n      if (!root.current?.contains(e.target as Node)) {\n        setOpenState(null);\n        // Pointerdown's default focus move runs after this listener; restore after release.\n        window.setTimeout(open.restore, 100);\n      }\n    };\n    window.addEventListener('keydown', key);\n    window.addEventListener('pointerdown', press, true);\n    return () => { window.removeEventListener('keydown', key); window.removeEventListener('pointerdown', press, true); };\n  }, [open]);\n  return (\n    <FanContext.Provider value={{ open, setOpen }}>\n      <div ref={root} role=\"toolbar\" aria-label={props['aria-label']} className={className ? `${ROW} ${className}` : ROW}>{children}</div>\n    </FanContext.Provider>\n  );\n}\n\n/** What the bar is about right now: \"Canvas\", \"Text\", \"Ink\", \"3 selected\". */\nfunction FanLabel({ children }: { children: React.ReactNode }) {\n  return <div className={LABEL}>{children}</div>;\n}\n\nexport interface FanOption<V extends string> { value: V; label: string; icon: React.ReactNode; shortcut?: string }\n\nexport interface FanPickerProps<V extends string> {\n  /** The group's name: \"Tool\". */\n  label: string;\n  value: V;\n  options: FanOption<V>[];\n  onValueChange: (value: V) => void;\n  /** up: the fan rises from the cap (a bar at the bottom). both: it opens above and below, centred. */\n  direction?: 'up' | 'both';\n}\n\n/** The current choice; pressing it fans the other choices out from behind it. */\nfunction FanPicker<V extends string>({ label, value, options, onValueChange, direction = 'up' }: FanPickerProps<V>) {\n  const { open, setOpen } = useFan();\n  const id = React.useId();\n  const isOpen = open?.id === id;\n  const cap = React.useRef<HTMLButtonElement>(null);\n  const items = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const [step, setStep] = React.useState(0);\n  const current = options.find((o) => o.value === value) ?? options[0];\n  const others = options.filter((o) => o.value !== current.value);\n\n  // One slot is a cap plus the bar's gap, measured as the fan opens so it follows the theme (and\n  // never reads a size from before the styles arrived).\n  const measure = () => {\n    const el = cap.current; if (!el) return;\n    const bar = el.closest('.mu-fan');\n    const gap = bar ? parseFloat(getComputedStyle(bar).columnGap) || 0 : 0;\n    setStep(el.offsetHeight + gap);\n  };\n  React.useEffect(() => { if (isOpen) items.current[0]?.focus(); }, [isOpen]);\n\n  const slot = (k: number) => (direction === 'up' ? -(k + 1) : (k % 2 === 0 ? -1 : 1) * (Math.floor(k / 2) + 1));\n  const choose = (v: V) => { onValueChange(v); setOpen(null); cap.current?.focus(); };\n  const toggle = () => { if (!isOpen) measure(); setOpen(isOpen ? null : { id, restore: () => cap.current?.focus() }); };\n  // Up moves away from the cap along the fan, down back toward it (list order in both directions).\n  const move = (e: React.KeyboardEvent, k: number) => {\n    if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;\n    e.preventDefault();\n    const next = e.key === 'ArrowUp' ? k + 1 : k - 1;\n    if (next < 0) { cap.current?.focus(); return; }\n    items.current[Math.min(others.length - 1, next)]?.focus();\n  };\n  const still = reduced();\n\n  return (\n    <div className=\"mu-fan-picker relative\">\n      <div role=\"listbox\" aria-label={label} aria-hidden={!isOpen} className=\"absolute inset-0 pointer-events-none\">\n        {others.map((o, k) => {\n          const y = isOpen ? slot(k) * step : 0;\n          return (\n            <button\n              key={o.value}\n              ref={(el) => { items.current[k] = el; }}\n              type=\"button\"\n              role=\"option\"\n              aria-selected={false}\n              aria-label={o.shortcut ? `${o.label} · ${o.shortcut}` : o.label}\n              title={o.shortcut ? `${o.label} · ${o.shortcut}` : o.label}\n              tabIndex={isOpen ? 0 : -1}\n              className={`${CAP} absolute inset-0 ${SPRING}`}\n              style={{\n                transform: `translateY(${y}px)`,\n                opacity: isOpen ? 1 : 0,\n                pointerEvents: isOpen ? 'auto' : 'none',\n                transitionProperty: still ? 'opacity' : 'transform, opacity',\n                transitionDelay: !still && isOpen ? `calc(${k} * var(--mu-motion-fan-stagger))` : undefined,\n              }}\n              onClick={() => choose(o.value)}\n              onKeyDown={(e) => move(e, k)}\n            >\n              {o.icon}\n            </button>\n          );\n        })}\n      </div>\n      <button\n        ref={cap}\n        type=\"button\"\n        aria-haspopup=\"listbox\"\n        aria-expanded={isOpen}\n        aria-label={`${label}: ${current.label}`}\n        title={`${label}: ${current.label}`}\n        className={`${CAP} relative`}\n        onClick={toggle}\n      >\n        {current.icon}\n      </button>\n    </div>\n  );\n}\n\nexport interface FanTrayProps {\n  /** The options' name: \"Ink\", \"Actions\". */\n  label: string;\n  /** The cap's glyph when folded. */\n  icon: React.ReactNode;\n  children: React.ReactNode;\n}\n\n/** An options cap that stretches sideways into a capsule of more controls; ‹ folds it. */\nfunction FanTray({ label, icon, children }: FanTrayProps) {\n  const { open, setOpen } = useFan();\n  const id = React.useId();\n  const isOpen = open?.id === id;\n  const cap = React.useRef<HTMLButtonElement>(null);\n  const shell = React.useRef<HTMLDivElement>(null);\n  const inner = React.useRef<HTMLDivElement>(null);\n  const [width, setWidth] = React.useState<number | null>(null);\n\n  React.useLayoutEffect(() => {\n    if (!isOpen) { setWidth(null); return; }\n    setWidth(inner.current?.scrollWidth ?? null);\n  }, [isOpen, children]);\n  React.useEffect(() => {\n    if (!isOpen) return;\n    const first = inner.current?.querySelector<HTMLElement>('button, [tabindex=\"0\"]');\n    first?.focus();\n  }, [isOpen]);\n\n  const toggle = () => { setOpen(isOpen ? null : { id, restore: () => cap.current?.focus() }); };\n  const fold = () => { setOpen(null); requestAnimationFrame(() => cap.current?.focus()); };\n  const still = reduced();\n\n  return (\n    <div\n      ref={shell}\n      className={`${isOpen ? TRAY : `${TRAY} w-icon-button-tool-size`} ${SPRING}`}\n      style={{ width: isOpen && width ? width : undefined, transitionProperty: still ? 'none' : 'width' }}\n    >\n      <button\n        ref={cap}\n        type=\"button\"\n        aria-expanded={isOpen}\n        aria-label={label}\n        title={label}\n        className={`${CAP} absolute left-0 top-0 ${SPRING}`}\n        style={{ opacity: isOpen ? 0 : 1, pointerEvents: isOpen ? 'none' : 'auto', transitionProperty: 'opacity' }}\n        tabIndex={isOpen ? -1 : 0}\n        onClick={toggle}\n      >\n        {icon}\n      </button>\n      <BaseToolbar.Root\n        ref={inner}\n        aria-label={label}\n        aria-hidden={!isOpen}\n        className={`mu-fan-tray-inner inline-flex items-center gap-toolbar-gap px-toolbar-pad whitespace-nowrap ${SPRING}`}\n        style={{ opacity: isOpen ? 1 : 0, visibility: isOpen ? 'visible' : 'hidden', transitionProperty: 'opacity' }}\n      >\n        {children}\n        <BaseToolbar.Button aria-label={`Fold ${label}`} title=\"Fold\" className={`${CAP} mu-fan-fold`} onClick={fold}>\n          <span aria-hidden className=\"type-toolbar-search\">‹</span>\n        </BaseToolbar.Button>\n      </BaseToolbar.Root>\n    </div>\n  );\n}\n\nFan.Label = FanLabel;\nFan.Picker = FanPicker;\nFan.Tray = FanTray;\n"
    }
  ],
  "docs": "Agent guide: https://metalui.dev/r/fan.md. SwiftUI: MetalFan in the MetalUI Swift package."
}
