{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "split-button",
  "title": "Split Button",
  "description": "One default action with its variants folded under the arrow beside it: a wide button that does the obvious thing, and a narrow one next to it that opens a menu of its near-relatives. Reach for it wherever a screen has an obvious action and two or three cousins of it — Save and Save as draft, Merge and Squash and merge and Rebase and merge, Deploy and Deploy to staging and Roll back, Export CSV and Export XLSX, Send and Schedule send, Invite and Copy invite link, Publish and Publish privately, Download and Download as PDF, Reply and Reply all, Run and Run with options, Approve and Approve with comment, Delete and Delete and archive, Add and Add from template, Print and Print selection — anywhere the default is right nine times out of ten and the tenth needs a different one rather than a different screen. Common asks it answers: \"split button react\", \"shadcn split button\", \"shadcn button with dropdown\", \"button with arrow dropdown react\", \"button with chevron that opens a menu\", \"primary action with secondary actions react\", \"default action plus more options button\", \"save and save as draft button\", \"merge button with dropdown like github\", \"deploy button with options\", \"export button with format menu\", \"send later button\", \"shadcn button-group with dropdown-menu example\", \"combine button and dropdown menu shadcn\", \"split button without radix\", \"dropdown button no dependencies\", \"menu button pattern react\", \"aria-haspopup menu button react\", \"accessible dropdown button\", \"two buttons joined together css\", \"remove double border between adjacent buttons\", \"button group double border 2px\", \"rounded corners between two joined buttons\", \"focus ring clipped by next button\", \"focus ring cut off in button group\", \"focus-visible z-10 button group\", \"no visible divider between filled buttons\", \"split button divider not showing\", \"button group rtl broken corners\", \"split button keyboard navigation\", \"arrow down should open the dropdown\", \"menu opens but arrow keys do nothing\", \"focus first menu item when the menu opens\", \"menu button focus management\", \"escape should close the menu and give focus back\", \"focus lost after closing a dropdown\", \"focus jumps to body when menu closes\", \"tab starts from the top of the page after closing a menu\", \"tab out of an open dropdown menu\", \"dropdown trigger submits my form\", \"button inside form submits instead of opening menu\", \"type=button on dropdown trigger\", \"chevron button has no accessible name\", \"icon button announces just button\", \"two buttons both read as button screen reader\", \"aria-label for the dropdown arrow\", \"name the arrow of a split button\", \"disabled menu item still focusable\", \"aria-disabled vs disabled menu item\", \"arrow keys skip my disabled menu item\", \"menu arrow keys should wrap around\", \"primary action disabled but menu still usable\", \"分割ボタン react\", \"ボタンの隣に矢印でメニュー\", \"既定の操作とその他の操作\", \"保存と下書き保存のボタン\", \"ドロップダウン付きボタン shadcn\", \"矢印キーでメニューを開く\", \"Escape でフォーカスを戻す\", \"メニュー項目を無効にしても読み上げたい\". Official shadcn/ui ships both halves and not the thing they make. Measured across its whole registry today — 63 items listed, 62 fetchable, `questionnaire` indexed and 404 on both style tracks, 223,287 bytes of `files[].content` concatenated — `SplitButton`, `split-button`, `splitButton`, `primaryAction` and `defaultAction` are every one of them a zero hit, and the route it documents is to compose `button-group` with `dropdown-menu` yourself. That is a real route, and `button-group` is real help: `[&>*:not(:first-child)]:rounded-l-none`, `[&>*:not(:first-child)]:border-l-0` and `[&>*]:focus-visible:relative [&>*]:focus-visible:z-10` between them collapse the doubled corner radius and the doubled border, and lift whichever child has focus so its ring is not painted over by the sibling that comes after it in the DOM. That last one is worth measuring rather than believing: in Chrome, two adjacent buttons with a 2px ring on the first draw the ring on three sides, and the same pair with `relative` and `z-10` on the focused half draws it on four — the ring extends 2px past the first button's border box, the sibling's box begins at exactly that edge, and `document.elementFromPoint` at that pixel answers with the sibling, whose background is painted later. Nothing is clipped and nothing errors; the ring just stops on the one side that says which half has focus. Two things are left over. The first is the divider, and it is left over precisely because the group did its job: of official's own `button` variants only `outline` carries a border, so `default`, `destructive` and `secondary` enter a group with none at all and `border-l-0` removes nothing — two filled halves then butt together as one unbroken block of colour, and where to press for the action and where to press for the menu stop being distinguishable. A filled split button needs a divider drawn in the foreground colour at low alpha, which is the opposite of what the group's rule does, and an outlined one needs one shared border rather than two. The second is every piece of the behaviour, and `dropdown-menu` does not write it either — it hands it to `radix-ui` as an npm dependency, 8,408 bytes of thin wrappers over `DropdownMenuPrimitive`. So grepping official for the aria proves nothing in either direction: `aria-haspopup`, `role=\"menu\"` and `menuitem` are zero hits across all 62 items because Radix supplies them at runtime. What the search does establish is that no file in the registry contains a split button's keyboard — `ArrowDown` occurs once in the whole registry, in `message-scroller`, and the single `aria-expanded` is the `has-aria-expanded:bg-muted/50` styling hook on `TableRow`. It is one thing to look at and two things to press, and everything here is a consequence of that sentence. Focus stops twice, so the arrow needs a name of its own, and it has to carry the default action: it is a chevron with no text, so left to itself it announces the word \"button\" one position away from another button that also announces \"button\", and a toolbar with three split buttons becomes three identical \"More options\". Here the name is built from the label — \"More Save options\" — and `labels.more` rewrites it in any language. Down and Up on the default action open the menu too, and they land inside it: opening while leaving focus on the default action is the version that looks right and is useless, because the keys that drive a menu are handled in the menu and the next Down does nothing, while handing focus to the arrow first is the other near-miss, since the menu is already open and the person's next Down walks from the arrow into the menu and skips the first item. Escape, Tab and choosing an item all put focus back on the half that opened the menu, which is not always the arrow; the element standing where they are is about to be removed, and a browser does not move focus when that happens — it drops to `<body>`, so the next Tab starts again from the top of the page. Tab is not prevented, because Tab means leave, but focus is moved onto the opener before the menu goes, so the browser computes the next stop from an element still in the document. A press outside is the one exception to all of that: the person has already said where they want to be, and pulling focus back would undo their click. The arrow is `type=\"button\"` and no prop can change it, because a split button lives in a form more often than not and `<button>` in a form defaults to submit — an arrow that inherited the default would post the form the moment somebody went looking for the second option; `type` reaches the default action, which is the half that may legitimately want to submit. An unavailable item gets `aria-disabled` and never the `disabled` attribute: `disabled` takes a button out of the focus order, so arrowing a menu of four whose third is unavailable walks 1, 2, 4, and the person learns not that the item is unavailable but that it does not exist. The arrow keys wrap rather than clamp, which is the menu convention and the opposite of the listboxes elsewhere in this registry — three items clamped make the last one a key that does nothing, which reads as the keyboard having broken. And the flat corners are logical (`rounded-e-none`, `rounded-s-none`) rather than left and right, so the unit mirrors itself on an Arabic or Hebrew page instead of coming apart. What you get: `SplitButton`, the whole unit, with `label` required because it is both the default action's text and the source of the arrow's accessible name; `actions`, the variants, each `{ label, onSelect, disabled?, icon?, id? }`, where an empty list renders no arrow at all because an arrow that opens an empty panel is a dead end; `variant` of `primary`, `outline` or `destructive`, each with the divider its own fill needs; `align` of `end` or `start` for which edge the menu lines up with; and the three ways to make something unavailable kept separate — `disabled` for both halves, `actionDisabled` for the default action alone with the menu still open for business, which is the combination the whole pattern exists for (a form that cannot be saved yet but can be saved as a draft, a pull request that cannot be merged but can still be closed), and `menuDisabled` for the arrow alone. `nextMenuIndex` is the wrap rule as a plain function, so the one place this differs from a listbox can be asserted on its own, and `defaultSplitButtonLabels` is the arrow's name as data. `onOpenChange` is reported from an effect rather than from inside the state setter, so a caller is told once per change instead of twice under strict mode. Per-part `className` for the wrapper, each half, the panel and the items. Nothing is portalled and nothing measures the viewport: the panel is absolutely positioned inside the wrapper, which is what a two-to-five item menu under a button needs — a menu that has to flip above the trigger near the bottom of a scroll container wants a positioning library, and that is a different component. One file, one lucide icon, and no `radix-ui`. Distinct from the neighbours: `confirm-button` is one action that asks twice, `loading-button` is one action that is busy, and `share-button`, `print-button` and `csv-export-button` are each a single action — none of them has a second half. `command-palette` is a searchable dialog over everything, reached by a shortcut rather than by a button. `multi-select`, `country-select`, `language-select` and `currency-select` are listboxes: they pick a value, they are named by a field label, and they clamp at the ends. This is the first `role=\"menu\"` in the registry — `menuitem` appears in none of the other 110 components — and a menu of actions is the one popup where the items are buttons that do something rather than options that select something. `bulk-action-bar` acts on a selection, and `keyboard-shortcuts` is the sheet that lists what the keys do.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/split-button.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronDown } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Where an arrow key lands in a menu, wrapping at both ends.\n *\n * Exported and separate because the wrap is the one rule that makes this a menu rather than a list,\n * and it is the opposite of what the components beside it in this registry do. `country-select`,\n * `language-select` and `multi-select` all clamp — `Math.min(active + 1, length - 1)` — which is\n * right for a listbox of 249 countries, where wrapping from Zimbabwe to Afghanistan is a 249-row\n * jump nobody asked for. A menu is three or four items and the convention is the other one: Down on\n * the last item returns to the first, Up on the first goes to the last, so a person arrowing\n * through the options never reaches a key that does nothing. Clamping a three-item menu makes the\n * last item a dead end, which reads as the keyboard having broken rather than as the end of a list.\n *\n * `from` below 0 means nothing is active yet, and the direction decides the entry point: Down opens\n * at the top, Up opens at the bottom. That is the same single rule the trigger needs, so the\n * trigger's two keys and the menu's two keys are one function and cannot drift apart.\n */\nexport function nextMenuIndex(count: number, from: number, delta: number): number {\n  if (!Number.isFinite(count) || count < 1) return -1\n  const span = Math.floor(count)\n  const step = Number.isFinite(delta) ? Math.trunc(delta) : 0\n  if (!Number.isFinite(from) || from < 0) return step < 0 ? span - 1 : 0\n  // Two modulos rather than one: a negative remainder is what `%` returns in JavaScript, and Up\n  // from position 0 is exactly that case.\n  return (((Math.trunc(from) + step) % span) + span) % span\n}\n\nexport interface SplitButtonLabels {\n  /**\n   * The arrow's accessible name, built from the default action's label.\n   *\n   * A function rather than a string because the name has to carry the default action: a toolbar\n   * with three split buttons has three arrows, and three announcements of \"More options\" tell a\n   * screen-reader user only that there are three of something.\n   */\n  more: (label: string) => string\n}\n\nexport const defaultSplitButtonLabels: SplitButtonLabels = {\n  more: (label) => `More ${label} options`,\n}\n\nexport interface SplitButtonAction {\n  /** Stable key for the item. The label is used when this is absent. */\n  id?: string\n  /** The item's text, and its accessible name. */\n  label: string\n  /** Run when the item is chosen. Not called for a disabled item. */\n  onSelect: () => void\n  /**\n   * Unavailable, but still shown and still reachable with the keyboard — see\n   * {@link SplitButtonProps.actions} for why this is `aria-disabled` rather than `disabled`.\n   */\n  disabled?: boolean\n  /** Decorative icon before the label. Hidden from screen readers by this component. */\n  icon?: React.ReactNode\n}\n\nexport type SplitButtonVariant = \"primary\" | \"outline\" | \"destructive\"\n\n/**\n * The classes shared by both halves, per variant.\n *\n * The seam is the part worth reading. Official shadcn/ui ships `button-group`, and it is the piece\n * of this problem that already exists: `[&>*:not(:first-child)]:rounded-l-none`,\n * `[&>*:not(:first-child)]:border-l-0`, `[&>*:not(:last-child)]:rounded-r-none` and\n * `[&>*]:focus-visible:relative [&>*]:focus-visible:z-10` between them collapse the doubled corner\n * radius and the doubled border, and lift whichever child has focus so its ring is not painted\n * over by the sibling that comes after it in the DOM.\n *\n * That last one is worth measuring rather than believing, because the lift looks like a stacking\n * nicety and is actually the difference between a ring and three quarters of one. Measured in\n * Chrome on two adjacent buttons with a 2px ring drawn as a box-shadow spread on the first:\n *\n * | the focused half          | ring drawn on        |\n * | ------------------------- | -------------------- |\n * | as it is                  | three sides          |\n * | `relative` + `z-10`       | four sides           |\n *\n * The ring extends 2px past the first button's border box, the sibling's border box begins at\n * exactly that edge, and `document.elementFromPoint` at that pixel answers with the sibling — so\n * the sibling's background, painted later in DOM order, covers the segment. Nothing is clipped and\n * nothing errors; the ring simply stops where the other half starts, on the one side that says\n * which of the two buttons focus is on.\n *\n * Two things are left over, and both are why the divider below is written out per variant instead\n * of inherited. The first is that the `border-l-0` rule has nothing to remove on a filled button:\n * of official's own `button` variants only `outline` carries a border, so `default`, `destructive`\n * and `secondary` enter a button group with no border at all and the rule removes nothing. Two\n * filled halves then butt together as one unbroken block of colour — rendered side by side in a\n * browser they read as one wide button with a chevron floating in it — and the place to press for\n * the default action and the place to press for the menu are not distinguishable — the single most\n * visible way a hand-built split button comes out wrong, and it comes out wrong *because* the group\n * did its job. So a filled split button needs a divider drawn in the foreground colour at low\n * alpha, which is what `seam` carries here; an outlined one needs the opposite, a single shared\n * border rather than two.\n *\n * The second is that `inline-flex` plus logical radius utilities (`rounded-e-none` on the default\n * action, `rounded-s-none` on the arrow) mirror themselves on an RTL page, where the physical\n * `rounded-l-none`/`rounded-r-none` pair does not. A flex row already follows `dir`, so the arrow\n * moves to the left on an Arabic or Hebrew page on its own; without logical radii the flat corners\n * stay on the sides they had in English and the unit comes apart.\n */\nconst VARIANT: Record<SplitButtonVariant, { base: string; seam: string }> = {\n  primary: {\n    base: \"bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring\",\n    seam: \"border-s border-s-primary-foreground/25\",\n  },\n  outline: {\n    base: \"border border-input bg-background hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring\",\n    // The arrow keeps its own inline-start border and the default action drops its inline-end one,\n    // so the line between them is one pixel rather than two.\n    seam: \"border-s\",\n  },\n  destructive: {\n    base: \"bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive\",\n    seam: \"border-s border-s-destructive-foreground/25\",\n  },\n}\n\nconst HALF_BASE =\n  \"inline-flex h-9 items-center justify-center gap-2 text-sm font-medium transition-colors \" +\n  \"focus-visible:outline-none focus-visible:ring-2 focus-visible:relative focus-visible:z-10 \" +\n  \"disabled:pointer-events-none disabled:opacity-50\"\n\nexport interface SplitButtonProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"button\">,\n    \"children\" | \"disabled\" | \"aria-haspopup\" | \"aria-expanded\"\n  > {\n  /**\n   * What the default action is called, in words.\n   *\n   * Required, and it is the prop that decides whether the arrow is usable by anyone not looking at\n   * the screen. The arrow has no text of its own — it is a chevron — so left to itself it announces\n   * the word \"button\", one position away from another button that also announces \"button\", and\n   * nothing says which one saves. From this it becomes \"More Save options\". It is also the default\n   * action's own visible text unless `children` says otherwise, so the usual call site passes it\n   * once and gets both.\n   */\n  label: string\n  /** The default action's content, when it needs more than `label` — an icon, a count, a badge. */\n  children?: React.ReactNode\n  /**\n   * The variants behind the arrow. An empty list renders no arrow at all: a split button whose menu\n   * has nothing in it is a button, and an arrow that opens an empty panel is a dead end.\n   *\n   * An item's `disabled` becomes `aria-disabled` and not the `disabled` attribute, deliberately. A\n   * `disabled` button is removed from the focus order, so arrowing down a menu of four items where\n   * the third is unavailable would walk 1, 2, 4 — and the one thing the person learns from that is\n   * nothing: the item they were looking for appears not to exist, rather than to exist and be\n   * unavailable right now. Keeping it focusable is what the ARIA practices ask for, and it is the\n   * difference between \"Squash and merge, dimmed\" and silence.\n   */\n  actions: readonly SplitButtonAction[]\n  variant?: SplitButtonVariant\n  /** Which edge of the unit the menu lines up with. Mirrors itself on an RTL page. */\n  align?: \"start\" | \"end\"\n  /**\n   * `type` for the default action only.\n   *\n   * The arrow is always `type=\"button\"` and this cannot change it. A split button lives in a form\n   * more often than not — Save / Save and close is the canonical one — and a `<button>` inside a\n   * form defaults to `type=\"submit\"`, so an arrow that inherited the default would post the form\n   * the moment somebody went looking for the second option. The default action is the half that may\n   * legitimately want to submit, so `type` reaches it and stops there.\n   */\n  type?: \"button\" | \"submit\" | \"reset\"\n  /** Both halves unavailable. */\n  disabled?: boolean\n  /**\n   * The default action alone unavailable, with the menu still open for business.\n   *\n   * This is the combination the pattern exists for: a form that cannot be saved yet but can be\n   * saved as a draft, a pull request that cannot be merged but can still be closed, an export whose\n   * default format is unavailable for this account. `disabled` would take the variants down with\n   * it.\n   */\n  actionDisabled?: boolean\n  /** The arrow alone unavailable, with the default action still pressable. */\n  menuDisabled?: boolean\n  /** Told when the menu opens and closes, for a caller that needs to know. */\n  onOpenChange?: (open: boolean) => void\n  labels?: Partial<SplitButtonLabels>\n  /** On the wrapper. The halves take `buttonClassName` / `triggerClassName`; other props reach the default action. */\n  className?: string\n  buttonClassName?: string\n  triggerClassName?: string\n  menuClassName?: string\n  itemClassName?: string\n}\n\n/**\n * One default action with its variants folded under the arrow beside it.\n *\n * For every place a screen has an obvious thing to do and two or three near-relatives of it: Save\n * and Save as draft, Merge and Squash and merge and Rebase and merge, Deploy and Deploy to staging\n * and Roll back, Export CSV and Export XLSX, Send and Schedule send, Invite and Copy invite link,\n * Publish and Publish privately, Download and Download as PDF, Reply and Reply all, Run and Run\n * with options, Approve and Approve with comment.\n *\n * **It is one thing to look at and two things to press.** That sentence is the whole component, and\n * every part of it below is a consequence:\n *\n *   - Two buttons means focus stops twice, so the arrow needs a name of its own that says what it\n *     belongs to (see {@link SplitButtonProps.label}).\n *   - The arrow is the half that owns the menu, so `aria-haspopup=\"menu\"`, `aria-expanded` and\n *     `aria-controls` go on it and not on the wrapper or the default action.\n *   - Down and Up on the *default action* open the menu too, and land inside it. Opening while\n *     leaving focus on the default action is the version that looks right and is useless: the keys\n *     that drive a menu are handled inside the menu, so the next Down does nothing. Handing focus\n *     to the arrow first is the other near-miss — the menu is already open at that point, so the\n *     person's next Down walks from the arrow into the menu and the first item is skipped.\n *   - Escape, choosing an item, and Tab all put focus back on the half that opened the menu, which\n *     is not always the arrow. A menu that unmounts while it still holds focus drops focus to\n *     `<body>`, and from there the next Tab starts at the top of the page.\n *   - An outside press is the exception: the person has already said where they want to be, and\n *     pulling focus back to the arrow would take it away from whatever they just pressed.\n *\n * The visual half of the problem is official shadcn/ui's `button-group`, and this does not depend on\n * it: see {@link VARIANT} for what that component already solves, the two things it leaves, and why\n * the seam here is drawn per variant.\n *\n * Nothing is portalled and nothing measures the viewport. The menu is absolutely positioned inside\n * the wrapper, which is what a two-to-five item menu under a button needs; a menu that has to flip\n * above the trigger near the bottom of a scroll container, or escape an `overflow: hidden`\n * ancestor, wants a positioning library and that is a different component.\n */\nexport function SplitButton({\n  label,\n  children,\n  actions,\n  variant = \"primary\",\n  align = \"end\",\n  type = \"button\",\n  disabled,\n  actionDisabled,\n  menuDisabled,\n  onOpenChange,\n  labels: labelOverrides,\n  className,\n  buttonClassName,\n  triggerClassName,\n  menuClassName,\n  itemClassName,\n  ...props\n}: SplitButtonProps) {\n  const labels = React.useMemo(\n    () => ({ ...defaultSplitButtonLabels, ...labelOverrides }),\n    [labelOverrides]\n  )\n  const generatedId = React.useId()\n  const menuId = `${generatedId}-menu`\n  const triggerId = `${generatedId}-trigger`\n\n  const [open, setOpen] = React.useState(false)\n  // Which item has focus, as a position rather than an id: the menu is given as an array and a\n  // position is what an arrow key moves. -1 is \"the menu is closed\".\n  const [active, setActive] = React.useState(-1)\n\n  const rootRef = React.useRef<HTMLDivElement | null>(null)\n  const actionRef = React.useRef<HTMLButtonElement | null>(null)\n  const triggerRef = React.useRef<HTMLButtonElement | null>(null)\n  // Which half to give focus back to. Set when the menu opens, read when it closes.\n  const openerRef = React.useRef<\"action\" | \"trigger\">(\"trigger\")\n\n  /**\n   * One ref object per position, created lazily.\n   *\n   * Object refs rather than `ref={(node) => …}` for a reason that is half about the browser and\n   * half about this repository's tests: a callback ref fires on every commit with a new function\n   * identity, so React detaches and reattaches it, and the test harness here only fills ref objects\n   * — a menu wired with callback refs has focus behaviour that cannot be asserted at all, which is\n   * how `date-range-preset` ended up with an untested roving tabindex.\n   */\n  const itemRefs = React.useRef<Array<React.RefObject<HTMLButtonElement | null>>>([])\n  const itemRef = (index: number) => {\n    while (itemRefs.current.length <= index) {\n      itemRefs.current.push(React.createRef<HTMLButtonElement>())\n    }\n    return itemRefs.current[index]\n  }\n\n  const count = actions.length\n  const hasMenu = count > 0\n  const menuOpen = open && hasMenu\n\n  // Reported from an effect rather than from the setter. A state updater has to be pure — React\n  // calls it twice in development's strict mode on purpose — so a caller's `onOpenChange` placed\n  // inside one is told twice for every open. Here the previous value is kept in a ref and the\n  // report happens after the commit that actually changed it.\n  const notifiedOpen = React.useRef(false)\n  React.useEffect(() => {\n    if (notifiedOpen.current === open) return\n    notifiedOpen.current = open\n    onOpenChange?.(open)\n  }, [open, onOpenChange])\n\n  const openMenu = (from: \"action\" | \"trigger\", at: number) => {\n    if (disabled || menuDisabled || !hasMenu) return\n    openerRef.current = from\n    setActive(at)\n    setOpen(true)\n  }\n\n  /**\n   * Closes the menu, and says whether focus comes back.\n   *\n   * `restoreFocus` is the whole reason this takes an argument. Escape, Tab and choosing an item are\n   * all the person still working inside the control, and the element they are standing on is about\n   * to be removed from the document — a browser does not move focus when that happens, it drops to\n   * `<body>`, which for a keyboard user means the next Tab starts again from the top of the page\n   * and for a screen-reader user means nothing says where they are. A press outside is the opposite\n   * situation: they have already chosen where to be, and taking focus back to the arrow would undo\n   * their click.\n   */\n  const closeMenu = React.useCallback((restoreFocus: boolean) => {\n    setActive(-1)\n    setOpen(false)\n    if (!restoreFocus) return\n    const opener = openerRef.current === \"action\" ? actionRef : triggerRef\n    opener.current?.focus()\n  }, [])\n\n  /**\n   * Moves real focus onto the active item, rather than pointing at it with `aria-activedescendant`.\n   *\n   * The two are not interchangeable here. A listbox may keep focus on the input and name the active\n   * row — that is what `country-select` does, and it has to, because the person is still typing into\n   * the filter box. A menu has no input: the items *are* the interface, each one is a `<button>`, and\n   * the ARIA practices for a menu describe moving focus. It also decides what Enter and Space mean\n   * without this component arranging anything, because the focused element is the real button.\n   *\n   * Keyed on `active` as well as `open` so one effect covers both opening (which lands on the first\n   * or last item) and every arrow key after it.\n   */\n  React.useEffect(() => {\n    // Bounded by the live count, not just by `active >= 0`. `actions` is a prop and can shrink under\n    // an open menu, and this effect is declared before the one that clamps `active`, so without the\n    // bound the commit in which the list shrank asks a ref that is no longer attached to anything —\n    // a focus call into a detached node, followed by the right one a render later.\n    if (!menuOpen || active < 0 || active >= count) return\n    itemRef(active).current?.focus()\n    // itemRef mutates a ref and is stable; listing it would mean memoising a function whose whole\n    // job is to be called during render.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [menuOpen, active, count])\n\n  // Close on an outside pointer press, in the capture phase so it beats a focus move.\n  React.useEffect(() => {\n    if (!menuOpen) return\n    function onPointerDown(event: PointerEvent) {\n      if (!rootRef.current?.contains(event.target as Node)) closeMenu(false)\n    }\n    document.addEventListener(\"pointerdown\", onPointerDown, true)\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown, true)\n  }, [menuOpen, closeMenu])\n\n  // A menu that is open while its own arrow becomes unavailable has no way to be closed by the\n  // person looking at it: the arrow no longer answers a press, and the items it was opened for may\n  // be unavailable too.\n  React.useEffect(() => {\n    if (menuOpen && (disabled || menuDisabled)) closeMenu(false)\n  }, [menuOpen, disabled, menuDisabled, closeMenu])\n\n  // The menu is given as a prop, so it can shrink underneath an open menu — a filter, a permission\n  // check, a refetch. Without this the active position points past the end and the focus effect\n  // asks a ref that holds nothing.\n  React.useEffect(() => {\n    if (!menuOpen) return\n    setActive((current) => (current >= count ? count - 1 : current))\n  }, [menuOpen, count])\n\n  function choose(index: number) {\n    const action = actions[index]\n    if (!action || action.disabled) return\n    // Closed first, so the focus restore below is not fighting whatever the handler does — a\n    // handler that opens a dialog would otherwise have focus pulled out from under it.\n    closeMenu(true)\n    action.onSelect()\n  }\n\n  function handleActionKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {\n    props.onKeyDown?.(event)\n    if (event.defaultPrevented || !hasMenu || disabled || menuDisabled) return\n    if (event.key !== \"ArrowDown\" && event.key !== \"ArrowUp\") return\n    // Without this the page scrolls as well as the menu opening.\n    event.preventDefault()\n    openMenu(\"action\", nextMenuIndex(count, -1, event.key === \"ArrowDown\" ? 1 : -1))\n  }\n\n  function handleTriggerKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {\n    switch (event.key) {\n      case \"ArrowDown\":\n      case \"ArrowUp\":\n        event.preventDefault()\n        openMenu(\"trigger\", nextMenuIndex(count, -1, event.key === \"ArrowDown\" ? 1 : -1))\n        break\n      case \"Escape\":\n        if (menuOpen) {\n          event.preventDefault()\n          closeMenu(true)\n        }\n        break\n    }\n  }\n\n  function handleMenuKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault()\n        setActive((current) => nextMenuIndex(count, current, 1))\n        break\n      case \"ArrowUp\":\n        event.preventDefault()\n        setActive((current) => nextMenuIndex(count, current, -1))\n        break\n      case \"Home\":\n        event.preventDefault()\n        setActive(0)\n        break\n      case \"End\":\n        event.preventDefault()\n        setActive(count - 1)\n        break\n      case \"Escape\":\n        event.preventDefault()\n        closeMenu(true)\n        break\n      case \"Tab\":\n        // Not prevented: Tab means leave, and the browser's own answer for where to go next is the\n        // right one. Focus is moved back onto the opener first so that answer is computed from an\n        // element that is still in the document — the menu is about to be removed, and a Tab from a\n        // detached element starts over at the top of the page.\n        closeMenu(true)\n        break\n    }\n  }\n\n  const half = VARIANT[variant]\n  const actionOff = Boolean(disabled || actionDisabled)\n  const menuOff = Boolean(disabled || menuDisabled)\n\n  return (\n    <div ref={rootRef} className={cn(\"relative inline-flex w-fit\", className)}>\n      <button\n        {...props}\n        ref={actionRef}\n        type={type}\n        disabled={actionOff}\n        onKeyDown={handleActionKeyDown}\n        className={cn(\n          HALF_BASE,\n          half.base,\n          \"rounded-md px-4\",\n          // Flat only on the side the arrow is on, and only when there is an arrow.\n          hasMenu && \"rounded-e-none\",\n          hasMenu && variant === \"outline\" && \"border-e-0\",\n          buttonClassName\n        )}\n      >\n        {children ?? label}\n      </button>\n\n      {hasMenu ? (\n        <button\n          ref={triggerRef}\n          id={triggerId}\n          // Never \"submit\", whatever `type` says — see SplitButtonProps.type.\n          type=\"button\"\n          disabled={menuOff}\n          aria-haspopup=\"menu\"\n          aria-expanded={menuOpen}\n          aria-controls={menuOpen ? menuId : undefined}\n          aria-label={labels.more(label)}\n          onClick={() => (menuOpen ? closeMenu(true) : openMenu(\"trigger\", 0))}\n          onKeyDown={handleTriggerKeyDown}\n          className={cn(\n            HALF_BASE,\n            half.base,\n            half.seam,\n            \"rounded-md rounded-s-none px-2\",\n            triggerClassName\n          )}\n        >\n          <ChevronDown\n            aria-hidden=\"true\"\n            className={cn(\n              \"size-4 transition-transform duration-200 motion-reduce:transition-none\",\n              menuOpen && \"rotate-180\"\n            )}\n          />\n        </button>\n      ) : null}\n\n      {menuOpen ? (\n        <div\n          id={menuId}\n          role=\"menu\"\n          // The attribute official shadcn/ui's own collapsing components key their keyframes off, so\n          // `menuClassName=\"data-[state=open]:animate-in\"` works in a project that has installed any\n          // of them.\n          data-state=\"open\"\n          // Named by the arrow, so the menu is announced as the thing the arrow said it would open\n          // rather than as an unnamed menu.\n          aria-labelledby={triggerId}\n          onKeyDown={handleMenuKeyDown}\n          className={cn(\n            \"absolute top-full z-50 mt-1 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md\",\n            align === \"end\" ? \"end-0\" : \"start-0\",\n            menuClassName\n          )}\n        >\n          {actions.map((action, index) => (\n            <button\n              key={action.id ?? action.label}\n              ref={itemRef(index)}\n              role=\"menuitem\"\n              type=\"button\"\n              // aria-disabled rather than disabled, so the item keeps its place in the arrow walk —\n              // see SplitButtonProps.actions.\n              aria-disabled={action.disabled || undefined}\n              // Roving: one tab stop for the whole menu, on the item that has focus.\n              tabIndex={index === active ? 0 : -1}\n              onClick={() => choose(index)}\n              className={cn(\n                \"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-start text-sm outline-none transition-colors\",\n                \"focus:bg-accent focus:text-accent-foreground\",\n                action.disabled\n                  ? \"pointer-events-none opacity-50\"\n                  : \"hover:bg-accent hover:text-accent-foreground\",\n                itemClassName\n              )}\n            >\n              {action.icon ? (\n                <span aria-hidden=\"true\" className=\"flex size-4 items-center justify-center\">\n                  {action.icon}\n                </span>\n              ) : null}\n              <span className=\"truncate\">{action.label}</span>\n            </button>\n          ))}\n        </div>\n      ) : null}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
