{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dropdown-menu",
  "type": "registry:component",
  "title": "Dropdown menu",
  "description": "Presents a compact list of actions. Menu roles and arrow-key navigation are built in.",
  "registryDependencies": [
    "https://vlak.dev/r/vlak-base.json",
    "https://vlak.dev/r/inter.json",
    "https://vlak.dev/r/button.json",
    "https://vlak.dev/r/vlak-lib.json",
    "https://vlak.dev/r/icons.json"
  ],
  "dependencies": [
    "@stylexjs/stylex"
  ],
  "devDependencies": [
    "@stylexjs/babel-plugin"
  ],
  "docs": "Vlak leaves are StyleX. Compile them with @stylexjs/babel-plugin (Vite: @stylexjs/unplugin, Next: @stylexjs/nextjs-plugin). If you would rather not run a compiler, import @noorddev/vlak-react instead: it ships precompiled with one stylesheet.",
  "files": [
    {
      "path": "vlak/dropdown-menu.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { vlak, mq } from \"./tokens.stylex\";\nimport { rs } from \"./rs\";\nimport { useMergedRefs } from \"./merge-refs\";\nimport { useOverlayPosition } from \"./use-overlay-position\";\n\nimport { Icon } from \"./icons\";\n\nexport interface DropdownMenuItem {\n  label?: React.ReactNode;\n  /** Plain text for type-ahead when `label` is not a string. */\n  searchText?: string;\n  onSelect?: () => void;\n  disabled?: boolean;\n  separator?: boolean;\n  /** Checked items use menuitemcheckbox semantics. */\n  checked?: boolean;\n  onCheckedChange?: (checked: boolean) => void;\n  /** A nested action menu. A parent item opens its children rather than selecting. */\n  items?: DropdownMenuItem[];\n}\n\nexport interface DropdownMenuProps extends React.HTMLAttributes<HTMLDivElement> {\n  label: React.ReactNode;\n  items: DropdownMenuItem[];\n  /** Preferred opening side. The menu still flips when needed to remain visible. */\n  side?: \"auto\" | \"top\" | \"bottom\";\n}\n\nexport const menuStyles = stylex.create({\n  select: {\n    position: \"relative\",\n    display: {\n      default: \"inline-block\",\n      [mq.phone]: \"block\",\n    },\n    minWidth: {\n      default: \"var(--rs-select-min-width, 11.25rem)\",\n      [mq.phone]: 0,\n    },\n    width: {\n      default: null,\n      [mq.phone]: \"100%\",\n    },\n  },\n  dropdown: {\n    boxSizing: \"border-box\",\n    display: \"flex\",\n    alignItems: \"center\",\n    justifyContent: \"space-between\",\n    gap: \"0.75rem\",\n    paddingBlock: {\n      default: \"0.5625rem\",\n      [mq.phone]: \"0.75rem\",\n    },\n    paddingInline: {\n      default: \"0.75rem\",\n      [mq.phone]: \"0.875rem\",\n    },\n    borderWidth: vlak.hairline,\n    borderStyle: \"solid\",\n    borderColor: vlak.controlBorder,\n    borderRadius: {\n      default: vlak.radiusSm,\n      [mq.phone]: vlak.radiusSm,\n    },\n    fontSize: {\n      default: \"0.875rem\",\n      [mq.phone]: vlak.controlFs,\n    },\n    color: vlak.ink,\n    letterSpacing: \"-0.01em\",\n    cursor: \"pointer\",\n    fontFamily: \"inherit\",\n    backgroundColor: \"transparent\",\n    width: \"100%\",\n    minHeight: {\n      default: vlak.hit,\n      [mq.phone]: vlak.hit,\n    },\n    outlineWidth: {\n      default: null,\n      \":focus-visible\": 2,\n    },\n    outlineStyle: {\n      default: null,\n      \":focus-visible\": \"solid\",\n    },\n    outlineColor: {\n      default: null,\n      \":focus-visible\": vlak.ink,\n    },\n    outlineOffset: {\n      default: null,\n      \":focus-visible\": 2,\n    },\n  },\n  menu: {\n    boxSizing: \"border-box\",\n    padding: \"0.25rem\",\n    borderWidth: vlak.hairline,\n    borderStyle: \"solid\",\n    borderColor: vlak.divider,\n    borderRadius: {\n      default: vlak.radiusSm,\n      [mq.phone]: vlak.radiusSm,\n    },\n    marginTop: {\n      default: \"0.375rem\",\n      [mq.phone]: 0,\n    },\n    overflow: \"hidden\",\n    backgroundColor: vlak.paper,\n    boxShadow: {\n      default: \"0 8px 24px rgba(0,0,0,0.06)\",\n      [mq.phone]: \"none\",\n    },\n  },\n  menuOverlay: {\n    position: \"absolute\",\n    top: \"100%\",\n    insetInlineStart: 0,\n    insetInlineEnd: 0,\n    zIndex: vlak.zFloat,\n  },\n  menuFixed: {\n    position: \"fixed\",\n    zIndex: vlak.zFloat,\n    marginTop: 0,\n    minWidth: \"10rem\",\n  },\n  menuCal: {\n    insetInlineEnd: \"auto\",\n    padding: \"0.75rem\",\n  },\n  menuCombobox: {\n    position: \"absolute\",\n    top: \"100%\",\n    insetInlineStart: 0,\n    insetInlineEnd: 0,\n    zIndex: vlak.zFloat,\n    maxHeight: \"15rem\",\n    overflowY: \"auto\",\n  },\n  item: {\n    boxSizing: \"border-box\",\n    display: \"flex\",\n    alignItems: \"center\",\n    gap: \"0.5rem\",\n    paddingBlock: {\n      default: \"0.5625rem\",\n      [mq.phone]: \"0.75rem\",\n    },\n    paddingInline: {\n      default: \"0.75rem\",\n      [mq.phone]: \"0.875rem\",\n    },\n    fontSize: {\n      default: \"0.875rem\",\n      [mq.phone]: vlak.controlFs,\n    },\n    color: {\n      default: vlak.ink,\n      \":focus-visible\": vlak.paper,\n      [mq.forcedColors]: { default: \"CanvasText\", \":focus-visible\": \"HighlightText\" },\n    },\n    letterSpacing: \"-0.01em\",\n    borderWidth: 0,\n    borderRadius: vlak.radiusSm,\n    width: \"100%\",\n    textAlign: \"start\",\n    cursor: \"pointer\",\n    backgroundColor: {\n      default: \"transparent\",\n      \":focus-visible\": vlak.ink,\n      [mq.forcedColors]: { default: \"Canvas\", \":focus-visible\": \"Highlight\" },\n    },\n    fontFamily: \"inherit\",\n    minHeight: {\n      default: vlak.hit,\n      [mq.phone]: vlak.hit,\n    },\n    /* Full-surface focus avoids framing each action as a separate button.\n       Forced colors retain an explicit outline alongside system-color fill. */\n    outlineWidth: {\n      default: 0,\n      [mq.forcedColors]: { default: 0, \":focus-visible\": 2 },\n    },\n    outlineStyle: {\n      default: \"none\",\n      [mq.forcedColors]: { default: \"none\", \":focus-visible\": \"solid\" },\n    },\n    outlineColor: \"HighlightText\",\n    outlineOffset: -2,\n    forcedColorAdjust: \"none\",\n  },\n  itemIndicator: {\n    display: \"inline-flex\",\n    alignItems: \"center\",\n    justifyContent: \"center\",\n    flex: \"0 0 1rem\",\n    width: \"1rem\",\n    height: \"1rem\",\n  },\n  itemLabel: {\n    flex: \"1 1 auto\",\n    minWidth: 0,\n    overflowWrap: \"anywhere\",\n  },\n  itemActive: {\n    backgroundColor: { default: vlak.dividerSubtle, \":focus-visible\": vlak.ink, [mq.forcedColors]: \"Highlight\" },\n    color: { default: vlak.ink, \":focus-visible\": vlak.paper, [mq.forcedColors]: \"HighlightText\" },\n  },\n  itemDisabled: {\n    color: vlak.gray,\n    cursor: \"default\",\n  },\n  sep: {\n    borderWidth: 0,\n    borderTopWidth: vlak.hairline,\n    borderTopStyle: \"solid\",\n    borderTopColor: vlak.divider,\n    marginBlock: \"0.25rem\",\n    marginInline: \"0.5rem\",\n  },\n});\n\n/** Text a menu item answers to for type-ahead. */\nexport function menuItemText(item: DropdownMenuItem): string {\n  if (item.searchText) return item.searchText;\n  return typeof item.label === \"string\" ? item.label : \"\";\n}\n\n/**\n * Next index whose text starts with `query`, searching forward from `from`\n * and wrapping. A repeated single letter cycles through its matches.\n */\nexport function typeAheadIndex(texts: string[], from: number, query: string): number {\n  const q = query.toLowerCase();\n  const repeated = q.length > 1 && q.split(\"\").every((c) => c === q[0]);\n  const needle = repeated ? q[0]! : q;\n  const start = repeated || q.length === 1 ? from + 1 : from;\n  for (let k = 0; k < texts.length; k++) {\n    const i = (start + k + texts.length) % texts.length;\n    if (texts[i]!.toLowerCase().startsWith(needle)) return i;\n  }\n  return -1;\n}\n\n/** Why a menu closed; decides whether focus goes back to the trigger. */\nexport type MenuCloseReason = \"escape\" | \"select\" | \"tab\" | \"outside\" | \"arrow\";\n\nexport interface MenuPanelProps {\n  id: string;\n  items: DropdownMenuItem[];\n  /** Class and style from an rs() call; the panel adds nothing of its own. */\n  className?: string;\n  style?: React.CSSProperties;\n  labelledBy?: string;\n  /** Which item takes focus on open (ArrowUp opens on the last). */\n  initial?: \"first\" | \"last\";\n  onClose: (reason: MenuCloseReason) => void;\n  /** ArrowLeft/ArrowRight, for a menubar to move to the neighbour menu. */\n  onHorizontal?: (dir: -1 | 1) => void;\n  /** The `role=\"menu\"` element, for outside-click checks. */\n  panelRef?: React.Ref<HTMLDivElement>;\n  /** Internal nested-menu return action. */\n  onBack?: () => void;\n}\n\n/**\n * The open `role=\"menu\"` list: roving tabindex, arrows wrap, Home/End,\n * type-ahead, Escape and Tab close. Focus lands on the first item on mount.\n */\nexport function MenuPanel({\n  id,\n  items,\n  className,\n  style,\n  labelledBy,\n  initial = \"first\",\n  onClose,\n  onHorizontal,\n  panelRef,\n  onBack,\n}: MenuPanelProps) {\n  const actionable = items.filter((item) => !item.separator);\n  const enabled = actionable.map((item, index) => (item.disabled ? -1 : index)).filter((i) => i >= 0);\n  const [active, setActive] = React.useState(() =>\n    initial === \"last\" ? (enabled[enabled.length - 1] ?? 0) : (enabled[0] ?? 0),\n  );\n  const itemRefs = React.useRef<Array<HTMLButtonElement | null>>([]);\n  const [submenu, setSubmenu] = React.useState<number | null>(null);\n  const subAnchor = React.useRef<HTMLButtonElement | null>(null);\n  subAnchor.current = submenu === null ? null : itemRefs.current[submenu] ?? null;\n  const typed = React.useRef({ buffer: \"\", at: 0 });\n  const typedTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\n  React.useEffect(() => {\n    // A positioned popover mounts hidden while its bounds are measured.\n    // Browsers cannot focus its items until the visible commit has landed.\n    if (style?.visibility !== \"hidden\") itemRefs.current[active]?.focus();\n  }, [active, style?.visibility]);\n\n  React.useEffect(() => () => clearTimeout(typedTimer.current), []);\n\n  const choose = (item: DropdownMenuItem) => {\n    if (item.disabled) return;\n    if (item.items?.length) { setSubmenu(actionable.indexOf(item)); return; }\n    onClose(\"select\");\n    if (item.checked !== undefined) item.onCheckedChange?.(!item.checked);\n    item.onSelect?.();\n  };\n\n  const step = (dir: -1 | 1) => {\n    if (enabled.length === 0) return;\n    setSubmenu(null);\n    const pos = enabled.indexOf(active);\n    setActive(enabled[(pos + dir + enabled.length) % enabled.length]!);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if ((e.target as Element).closest('[role=\"menu\"]') !== e.currentTarget) return;\n    const rtl = getComputedStyle(e.currentTarget).direction === \"rtl\";\n    const forward = rtl ? \"ArrowLeft\" : \"ArrowRight\";\n    const backward = rtl ? \"ArrowRight\" : \"ArrowLeft\";\n    if (e.key === forward && actionable[active]?.items?.length) {\n      e.preventDefault(); setSubmenu(active); return;\n    }\n    if (e.key === backward && onBack) { e.preventDefault(); onBack(); return; }\n    switch (e.key) {\n      case \"ArrowDown\":\n        e.preventDefault();\n        step(1);\n        return;\n      case \"ArrowUp\":\n        e.preventDefault();\n        step(-1);\n        return;\n      case \"Home\":\n        e.preventDefault();\n        setSubmenu(null);\n        if (enabled.length) setActive(enabled[0]!);\n        return;\n      case \"End\":\n        e.preventDefault();\n        setSubmenu(null);\n        if (enabled.length) setActive(enabled[enabled.length - 1]!);\n        return;\n      case \"ArrowLeft\":\n      case \"ArrowRight\":\n        if (onHorizontal) {\n          e.preventDefault();\n          onHorizontal(e.key === \"ArrowLeft\" ? -1 : 1);\n        }\n        return;\n      case \"Escape\":\n        e.preventDefault();\n        if (onBack) onBack(); else onClose(\"escape\");\n        return;\n      case \"Tab\":\n        onClose(\"tab\");\n        return;\n      case \"Enter\":\n      case \" \": {\n        e.preventDefault();\n        const item = actionable[active];\n        if (item) choose(item);\n        return;\n      }\n      default:\n        break;\n    }\n    if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {\n      e.preventDefault();\n      const now = Date.now();\n      const buffer = now - typed.current.at < 500 ? typed.current.buffer + e.key : e.key;\n      typed.current = { buffer, at: now };\n      clearTimeout(typedTimer.current);\n      typedTimer.current = setTimeout(() => {\n        typed.current = { buffer: \"\", at: 0 };\n      }, 500);\n      const texts = actionable.map((item) => (item.disabled ? \"\" : menuItemText(item)));\n      const next = typeAheadIndex(texts, active, buffer);\n      if (next >= 0) { setSubmenu(null); setActive(next); }\n    }\n  };\n\n  let actionIndex = -1;\n  const indicator = rs([\"rs-menu-item-indicator\"], menuStyles.itemIndicator);\n  const label = rs([\"rs-menu-item-label\"], menuStyles.itemLabel);\n  return (\n    <div\n      ref={panelRef}\n      id={id}\n      role=\"menu\"\n      aria-labelledby={labelledBy}\n      className={className}\n      style={style}\n      onKeyDown={onKeyDown}\n    >\n      {items.map((item, index) => {\n        if (item.separator) {\n          const sep = rs([\"rs-menu-sep\"], menuStyles.sep);\n          return <hr key={`sep-${index}`} className={sep.className} style={sep.style} />;\n        }\n        actionIndex++;\n        const current = actionIndex;\n        const row = rs(\n          [\"rs-menu-item\", current === active && \"rs-menu-item-active\", item.disabled && \"rs-menu-item-disabled\"],\n          menuStyles.item,\n          current === active && menuStyles.itemActive,\n          item.disabled && menuStyles.itemDisabled,\n        );\n        return (\n          <button\n            key={index}\n            id={`${id}-item-${current}`}\n            ref={(el) => {\n              itemRefs.current[current] = el;\n            }}\n            type=\"button\"\n            role={item.checked !== undefined ? \"menuitemcheckbox\" : \"menuitem\"}\n            aria-checked={item.checked}\n            aria-haspopup={item.items?.length ? \"menu\" : undefined}\n            aria-expanded={item.items?.length ? submenu === current : undefined}\n            aria-controls={item.items?.length && submenu === current ? `${id}-sub-${current}` : undefined}\n            tabIndex={current === active ? 0 : -1}\n            aria-disabled={item.disabled || undefined}\n            className={row.className}\n            style={row.style}\n            onPointerEnter={() => {\n              if (!item.disabled && current !== active) { setSubmenu(null); setActive(current); }\n            }}\n            onFocus={() => { if (!item.disabled) setActive(current); }}\n            onClick={() => choose(item)}\n          >\n            {item.checked !== undefined && <span {...indicator} aria-hidden=\"true\">{item.checked ? <Icon name=\"check\" size={16} /> : null}</span>}\n            <span {...label}>{item.label}</span>\n            {item.items?.length ? <span {...indicator} aria-hidden=\"true\"><Icon name=\"chevron-right\" size={16} /></span> : null}\n          </button>\n        );\n      })}\n      {submenu !== null && submenu === active && actionable[submenu]?.items?.length ? <NestedMenu\n        id={`${id}-sub-${submenu}`}\n        items={actionable[submenu]!.items!}\n        labelledBy={`${id}-item-${submenu}`}\n        anchor={subAnchor}\n        onClose={onClose}\n        onBack={() => { setSubmenu(null); itemRefs.current[active]?.focus(); }}\n      /> : null}\n    </div>\n  );\n}\n\nfunction NestedMenu({ anchor, ...props }: Omit<MenuPanelProps, \"panelRef\" | \"style\" | \"className\"> & { anchor: React.RefObject<HTMLButtonElement | null> }) {\n  const panel = React.useRef<HTMLDivElement>(null);\n  const position = useOverlayPosition(true, panel, anchor, undefined, \"inline-end\");\n  const menu = rs([\"rs-menu\", \"rs-menu-nested\"], menuStyles.menu, menuStyles.menuFixed);\n  return <MenuPanel {...props} panelRef={panel} className={menu.className} style={{ ...menu.style, ...position }} />;\n}\n\n/** Action menu with menu semantics and keyboard navigation. */\nexport const DropdownMenu = React.forwardRef<HTMLDivElement, DropdownMenuProps>(function DropdownMenu(\n  { label, items, side = \"auto\", className, style, ...props },\n  ref,\n) {\n  const idBase = React.useId();\n  const triggerId = `${idBase}-trigger`;\n  const menuId = `${idBase}-menu`;\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const setRootRef = useMergedRefs(rootRef, ref);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const [open, setOpen] = React.useState(false);\n  const [initial, setInitial] = React.useState<\"first\" | \"last\">(\"first\");\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const placement = useOverlayPosition(open, panelRef, triggerRef, undefined, \"bottom\", { side });\n\n  React.useEffect(() => {\n    if (!open) return;\n    const onPointerDown = (e: PointerEvent) => {\n      if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", onPointerDown);\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown);\n  }, [open]);\n\n  const openMenu = (at: \"first\" | \"last\") => {\n    setInitial(at);\n    setOpen(true);\n  };\n\n  const close = (reason: MenuCloseReason) => {\n    setOpen(false);\n    if (reason !== \"outside\") triggerRef.current?.focus();\n  };\n\n  const onTriggerKeyDown = (e: React.KeyboardEvent) => {\n    if (!open) {\n      if (e.key === \"ArrowDown\" || e.key === \"Enter\" || e.key === \" \") {\n        e.preventDefault();\n        openMenu(\"first\");\n      } else if (e.key === \"ArrowUp\") {\n        e.preventDefault();\n        openMenu(\"last\");\n      }\n      return;\n    }\n    if (e.key === \"Escape\") {\n      e.preventDefault();\n      close(\"escape\");\n    }\n  };\n\n  const root = rs([\"rs-select\", className], menuStyles.select);\n  const trigger = rs([\"rs-dropdown\"], menuStyles.dropdown);\n  const menu = rs([\"rs-menu\"], menuStyles.menu, menuStyles.menuOverlay);\n  return (\n    <div ref={setRootRef} className={root.className} style={{ ...root.style, ...style }} {...props}>\n      <button\n        ref={triggerRef}\n        id={triggerId}\n        type=\"button\"\n        className={trigger.className}\n        style={trigger.style}\n        aria-haspopup=\"menu\"\n        aria-expanded={open}\n        aria-controls={open ? menuId : undefined}\n        onClick={() => (open ? close(\"select\") : openMenu(\"first\"))}\n        onKeyDown={onTriggerKeyDown}\n      >\n        <span>{label}</span>\n        <Icon name=\"chevron-right\" rotate={90} />\n      </button>\n      {open && (\n        <MenuPanel\n          panelRef={panelRef}\n          id={menuId}\n          items={items}\n          labelledBy={triggerId}\n          initial={initial}\n          className={menu.className}\n          style={{ ...menu.style, ...placement }}\n          onClose={close}\n        />\n      )}\n    </div>\n  );\n});\n",
      "type": "registry:component",
      "target": "components/vlak/dropdown-menu.tsx"
    },
    {
      "path": "vlak/use-overlay-position.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\n/** Keep lightweight overlays inside the visual viewport. The native top layer avoids clipping. */\nexport function useOverlayPosition(\n  open: boolean,\n  panel: React.RefObject<HTMLElement | null>,\n  anchor: React.RefObject<HTMLElement | null>,\n  point?: { x: number; y: number } | null,\n  placement: \"bottom\" | \"inline-end\" = \"bottom\",\n  options?: {\n    popover?: \"auto\" | \"manual\";\n    edge?: number;\n    matchAnchorWidth?: boolean;\n    /** Preferred block side. Collision handling may use the opposite side. */\n    side?: \"auto\" | \"top\" | \"bottom\";\n  },\n) {\n  const [position, setPosition] = React.useState<React.CSSProperties>({ visibility: \"hidden\" });\n  React.useLayoutEffect(() => {\n    const element = panel.current;\n    if (!open || !element) return;\n    const native = typeof element.showPopover === \"function\";\n    if (native) {\n      element.setAttribute(\"popover\", options?.popover ?? \"manual\");\n      if (!element.matches(\":popover-open\")) element.showPopover();\n    }\n    const place = () => {\n      const viewport = window.visualViewport;\n      const edge = Math.max(0, options?.edge ?? 8);\n      const leftEdge = (viewport?.offsetLeft ?? 0) + edge;\n      const topEdge = (viewport?.offsetTop ?? 0) + edge;\n      const viewportWidth = viewport?.width ?? window.innerWidth;\n      const viewportHeight = viewport?.height ?? window.innerHeight;\n      const rightEdge = leftEdge + viewportWidth - edge * 2;\n      const bottomEdge = topEdge + viewportHeight - edge * 2;\n      const target = anchor.current?.getBoundingClientRect();\n      const paint = getComputedStyle(element);\n      const borderX = (Number.parseFloat(paint.borderLeftWidth) || 0) + (Number.parseFloat(paint.borderRightWidth) || 0);\n      const borderY = (Number.parseFloat(paint.borderTopWidth) || 0) + (Number.parseFloat(paint.borderBottomWidth) || 0);\n      const matchAnchor = options?.matchAnchorWidth !== false;\n      let panelWidth = element.scrollWidth + borderX;\n      if (!matchAnchor) {\n        // Measure the authored width without the last placement’s viewport clamp.\n        // Restoring it here also keeps resize observation tied to the final layout.\n        const { width, minWidth, maxWidth } = element.style;\n        element.style.width = \"\";\n        element.style.minWidth = \"\";\n        element.style.maxWidth = \"\";\n        panelWidth = element.getBoundingClientRect().width;\n        Object.assign(element.style, { width, minWidth, maxWidth });\n      }\n      const width = Math.min(Math.max(panelWidth, matchAnchor ? target?.width ?? 0 : 0), viewportWidth - edge * 2);\n      const height = Math.min(element.scrollHeight + borderY, viewportHeight - edge * 2);\n      let left = point?.x ?? target?.left ?? leftEdge;\n      let top = point?.y ?? (target ? target.bottom + 6 : topEdge);\n      if (placement === \"inline-end\" && target) {\n        const rtl = getComputedStyle(anchor.current!).direction === \"rtl\";\n        left = rtl ? target.left - width - 4 : target.right + 4;\n        if (left + width > rightEdge) left = target.left - width - 4;\n        if (left < leftEdge) left = target.right + 4;\n        top = target.top;\n        if (top + height > bottomEdge) top = bottomEdge - height;\n      } else if (target && point?.y === undefined) {\n        const below = target.bottom + 6;\n        const above = target.top - height - 6;\n        if (options?.side === \"top\") {\n          top = above >= topEdge || below + height > bottomEdge ? above : below;\n        } else {\n          top = below + height <= bottomEdge || above < topEdge ? below : above;\n        }\n      } else if (top + height > bottomEdge) {\n        top = point?.y !== undefined ? point.y - height : bottomEdge - height;\n      }\n      left = Math.max(leftEdge, Math.min(left, rightEdge - width));\n      top = Math.max(topEdge, Math.min(top, bottomEdge - height));\n      const next: React.CSSProperties = {\n        position: \"fixed\", inset: \"auto\", left, top, margin: 0,\n        minWidth: matchAnchor ? Math.min(target?.width ?? 0, viewportWidth - edge * 2) : 0,\n        maxWidth: viewportWidth - edge * 2, maxHeight: viewportHeight - edge * 2,\n        width, overflow: \"auto\", visibility: \"visible\",\n      };\n      setPosition((previous) => JSON.stringify(previous) === JSON.stringify(next) ? previous : next);\n    };\n    place();\n    const observer = typeof ResizeObserver === \"undefined\" ? undefined : new ResizeObserver(place);\n    observer?.observe(element);\n    if (anchor.current) observer?.observe(anchor.current);\n    window.addEventListener(\"resize\", place);\n    window.addEventListener(\"scroll\", place, true);\n    window.visualViewport?.addEventListener(\"resize\", place);\n    window.visualViewport?.addEventListener(\"scroll\", place);\n    return () => {\n      observer?.disconnect();\n      window.removeEventListener(\"resize\", place);\n      window.removeEventListener(\"scroll\", place, true);\n      window.visualViewport?.removeEventListener(\"resize\", place);\n      window.visualViewport?.removeEventListener(\"scroll\", place);\n    };\n  }, [open, panel, anchor, point?.x, point?.y, placement, options?.popover, options?.edge, options?.matchAnchorWidth, options?.side]);\n  return position;\n}\n",
      "type": "registry:file",
      "target": "components/vlak/use-overlay-position.ts"
    },
    {
      "path": "vlak/styles/dropdown-menu.css",
      "content": "/* ── dropdown-menu: generated from packages/react/src/components/dropdown-menu.tsx ── */\n.rs-select{position:relative;display:inline-block;min-width:var(--rs-select-min-width, 11.25rem)}\n@media (max-width: 640px){.rs-select{display:block;min-width:0;width:100%}}\n.rs-dropdown{box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;gap:0.75rem;padding-block:0.5625rem;padding-inline:0.75rem;border-width:1px;border-style:solid;border-color:var(--control-border);border-radius:var(--radius-sm);font-size:0.875rem;color:var(--text);letter-spacing:-0.01em;cursor:pointer;font-family:inherit;background-color:transparent;width:100%;min-height:var(--hit)}\n.rs-dropdown:focus-visible{outline-width:2px;outline-style:solid;outline-color:var(--text);outline-offset:2px}\n@media (max-width: 640px){.rs-dropdown{padding-block:0.75rem;padding-inline:0.875rem;border-radius:var(--radius-sm);font-size:var(--control-fs);min-height:var(--hit)}}\n.rs-menu,.rs-menu.rs-context-menu-pin,.rs-menu.rs-date-picker-cal-menu,.rs-menu.rs-select-list{box-sizing:border-box;padding:0.25rem;border-width:1px;border-style:solid;border-color:var(--divider);border-radius:var(--radius-sm);margin-top:0.375rem;overflow:hidden;background-color:var(--bg);box-shadow:0 8px 24px rgba(0,0,0,0.06)}\n@media (max-width: 640px){.rs-menu,.rs-menu.rs-context-menu-pin,.rs-menu.rs-date-picker-cal-menu,.rs-menu.rs-select-list{border-radius:var(--radius-sm);margin-top:0;box-shadow:none}}\n.rs-menu.rs-date-picker-cal-menu,.rs-menu,.rs-menu.rs-select-list{position:absolute;top:100%;inset-inline-start:0;inset-inline-end:0;z-index:var(--z-float)}\n.rs-menu.rs-context-menu-pin,.rs-menu-nested{position:fixed;z-index:var(--z-float);margin-top:0;min-width:10rem}\n.rs-menu.rs-date-picker-cal-menu{inset-inline-end:auto;padding:0.75rem}\n.rs-menu{position:absolute;top:100%;inset-inline-start:0;inset-inline-end:0;z-index:var(--z-float);max-height:15rem;overflow-y:auto}\n.rs-menu-item{box-sizing:border-box;display:flex;align-items:center;gap:0.5rem;padding-block:0.5625rem;padding-inline:0.75rem;font-size:0.875rem;color:var(--text);letter-spacing:-0.01em;border-width:0;border-radius:var(--radius-sm);width:100%;text-align:start;cursor:pointer;background-color:transparent;font-family:inherit;min-height:var(--hit);outline-width:0;outline-style:none;outline-color:HighlightText;outline-offset:-2px;forced-color-adjust:none}\n.rs-menu-item:focus-visible{color:var(--bg);background-color:var(--text)}\n@media (max-width: 640px){.rs-menu-item{padding-block:0.75rem;padding-inline:0.875rem;font-size:var(--control-fs);min-height:var(--hit)}}\n@media (forced-colors: active){.rs-menu-item{color:CanvasText;background-color:Canvas;outline-width:0;outline-style:none}.rs-menu-item:focus-visible{color:HighlightText;background-color:Highlight;outline-width:2px;outline-style:solid}}\n.rs-menu-item-indicator{display:inline-flex;align-items:center;justify-content:center;flex:0 0 1rem;width:1rem;height:1rem}\n.rs-menu-item-label{flex:1 1 auto;min-width:0;overflow-wrap:anywhere}\n.rs-menu-item-active{background-color:var(--divider-subtle);color:var(--text)}\n.rs-menu-item-active:focus-visible{background-color:var(--text);color:var(--bg)}\n@media (forced-colors: active){.rs-menu-item-active{background-color:Highlight;color:HighlightText}}\n.rs-menu-item-disabled{color:var(--text-secondary);cursor:default}\n.rs-menu-sep{border-width:0;border-top-width:1px;border-top-style:solid;border-top-color:var(--divider);margin-block:0.25rem;margin-inline:0.5rem}\n",
      "type": "registry:file",
      "target": "styles/vlak/dropdown-menu.css"
    }
  ],
  "meta": {
    "vlak": {
      "category": "actions",
      "classes": [
        "rs-menu",
        "rs-dropdown",
        "rs-menu-item",
        "rs-menu-item-indicator",
        "rs-menu-item-label",
        "rs-menu-item-active",
        "rs-menu-item-disabled",
        "rs-menu-sep",
        "rs-menu-nested"
      ],
      "snippet": "<div class=\"rs-menu\" role=\"menu\"><button class=\"rs-menu-item\" role=\"menuitem\">Rename</button><button class=\"rs-menu-item\" role=\"menuitem\">Duplicate</button><hr class=\"rs-menu-sep\" /><button class=\"rs-menu-item\" role=\"menuitem\">Delete</button></div>",
      "cssOnly": false,
      "registryDependencies": [
        "button",
        "vlak-lib",
        "icons"
      ],
      "aliases": [
        "Dropdown menu",
        "Menu",
        "Action menu",
        "Overflow menu"
      ],
      "example": "import { DropdownMenu } from \"@noorddev/vlak-react\";\n\n<DropdownMenu\n  label=\"Actions\"\n  side=\"top\"\n  items={[\n    { label: \"Rename\", onSelect: rename },\n    { label: \"Duplicate\", onSelect: duplicate },\n    { separator: true },\n    { label: \"Delete\", onSelect: remove },\n    { label: \"Archive\", disabled: true },\n  ]}\n/>",
      "usage": {
        "use": [
          "Three or more actions on one object behind a single trigger.",
          "Separators to group destructive actions at the end.",
          "Use side=\"top\" for controls anchored to a persistent footer or viewport edge. Placement remains collision-aware."
        ],
        "avoid": [
          "Choosing a value; use Select.",
          "Navigation links; use NavigationMenu."
        ]
      },
      "keyboard": [
        {
          "keys": "Arrow down, Enter, Space",
          "does": "Opens the menu on the first item"
        },
        {
          "keys": "Arrow up",
          "does": "Opens the menu on the last item"
        },
        {
          "keys": "Arrow down, Arrow up",
          "does": "Moves between items and wraps"
        },
        {
          "keys": "Arrow right, Arrow left",
          "does": "Opens a child menu or returns to its parent; directions reverse in RTL"
        },
        {
          "keys": "Home, End",
          "does": "First or last item"
        },
        {
          "keys": "Type letters",
          "does": "Moves to the next item starting with them"
        },
        {
          "keys": "Enter, Space",
          "does": "Selects the item and closes"
        },
        {
          "keys": "Escape",
          "does": "Closes and returns focus to the trigger"
        },
        {
          "keys": "Tab",
          "does": "Closes and moves on"
        }
      ],
      "a11y": [
        "The trigger is a <button> with aria-haspopup=\"menu\", aria-expanded, and aria-controls; label is its name.",
        "The panel is role=\"menu\" labelled by the trigger; items are <button role=\"menuitem\"> with one roving tab stop.",
        "Disabled items carry aria-disabled and are skipped; separators are <hr>. A click outside closes without moving focus.",
        "items opens a named nested menu. checked and onCheckedChange expose controlled menuitemcheckbox state. Escape returns to the immediate parent before closing the root menu."
      ]
    }
  }
}
