{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expandable-action-bar",
  "title": "Expandable Action Bar",
  "description": "Compact icon actions that expand into labeled controls on hover or focus with shared layout motion.",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/motion/expandable-action-bar.tsx",
      "content": "\"use client\";\n// beui.dev/components/blocks/expandable-action-bar\n\nimport { LayoutGroup, motion, type Transition, useReducedMotion } from \"motion/react\";\nimport {\n  type FocusEvent,\n  type MouseEvent,\n  type PointerEvent,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useDismiss } from \"@/lib/hooks/use-dismiss\";\nimport { useHoverGesture } from \"@/lib/hooks/use-hover-gesture\";\nimport { useTapGesture } from \"@/lib/hooks/use-tap-gesture\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ExpandableActionBarSize = \"sm\" | \"md\";\n\nexport type ExpandableActionBarItem = {\n  id: string;\n  label: ReactNode;\n  icon: ReactNode;\n  onClick?: () => void;\n  disabled?: boolean;\n  active?: boolean;\n  badge?: ReactNode;\n  shortcut?: ReactNode;\n};\n\nexport type ExpandableActionBarClassNames = {\n  root?: string;\n  track?: string;\n  item?: string;\n  activeItem?: string;\n  icon?: string;\n  label?: string;\n  badge?: string;\n  shortcut?: string;\n};\n\nexport interface ExpandableActionBarProps {\n  items: ExpandableActionBarItem[];\n  expanded?: boolean;\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n  activeId?: string;\n  onAction?: (item: ExpandableActionBarItem) => void;\n  size?: ExpandableActionBarSize;\n  /**\n   * Expand when a pointer that hovers rests on the bar. Default true. It also\n   * governs the touch equivalent: with no hover to reveal the labels, the\n   * first tap expands the bar and runs no action, and the second one acts.\n   * Set false to make every tap and click act immediately.\n   */\n  expandOnHover?: boolean;\n  expandOnFocus?: boolean;\n  collapseDelay?: number;\n  className?: string;\n  classNames?: ExpandableActionBarClassNames;\n  renderItem?: (item: ExpandableActionBarItem, state: { expanded: boolean; active: boolean }) => ReactNode;\n}\n\nconst ITEM_TRANSITION: Transition = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 34,\n  mass: 0.62,\n};\n\nconst LABEL_TRANSITION: Transition = {\n  type: \"spring\",\n  stiffness: 380,\n  damping: 32,\n  mass: 0.7,\n};\n\nconst SIZE_CLASS: Record<ExpandableActionBarSize, string> = {\n  sm: \"min-h-9 gap-1 p-1 text-xs\",\n  md: \"min-h-11 gap-1.5 p-1.5 text-sm\",\n};\n\nconst ITEM_SIZE_CLASS: Record<ExpandableActionBarSize, string> = {\n  sm: \"h-7 min-w-7 px-1.5\",\n  md: \"h-8 min-w-8 px-2\",\n};\n\nconst ICON_SIZE_CLASS: Record<ExpandableActionBarSize, string> = {\n  sm: \"h-3.5 w-3.5\",\n  md: \"h-4 w-4\",\n};\n\nfunction useControllableExpanded({\n  expanded,\n  defaultExpanded,\n  onExpandedChange,\n}: {\n  expanded?: boolean;\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n}) {\n  const [internalExpanded, setInternalExpanded] = useState(defaultExpanded ?? false);\n  const isControlled = expanded !== undefined;\n  const value = expanded ?? internalExpanded;\n\n  const setValue = useCallback(\n    (next: boolean) => {\n      if (!isControlled) setInternalExpanded(next);\n      onExpandedChange?.(next);\n    },\n    [isControlled, onExpandedChange],\n  );\n\n  return [value, setValue] as const;\n}\n\nexport function ExpandableActionBar({\n  items,\n  expanded,\n  defaultExpanded = false,\n  onExpandedChange,\n  activeId,\n  onAction,\n  size = \"md\",\n  expandOnHover = true,\n  expandOnFocus = true,\n  collapseDelay = 90,\n  className,\n  classNames,\n  renderItem,\n}: ExpandableActionBarProps) {\n  const reduce = useReducedMotion();\n  const layoutId = useId();\n  const [isExpanded, setIsExpanded] = useControllableExpanded({\n    expanded,\n    defaultExpanded,\n    onExpandedChange,\n  });\n  const [hoveredId, setHoveredId] = useState<string | null>(null);\n  // Set by the tap that expands the bar, and the reason the outside-tap\n  // dismisser exists at all — a hovering pointer has its own way out.\n  const [tapExpanded, setTapExpanded] = useState(false);\n  const collapseTimer = useRef<number | null>(null);\n  const trackRef = useRef<HTMLDivElement | null>(null);\n  // What the last gesture on an action was, and whether the bar was already\n  // expanded when it started. A click reports neither.\n  const tap = useTapGesture<boolean>();\n  const hover = useHoverGesture();\n\n  const clearCollapseTimer = useCallback(() => {\n    if (collapseTimer.current) window.clearTimeout(collapseTimer.current);\n    collapseTimer.current = null;\n  }, []);\n\n  const open = useCallback(() => {\n    clearCollapseTimer();\n    setIsExpanded(true);\n  }, [clearCollapseTimer, setIsExpanded]);\n\n  const close = useCallback(() => {\n    clearCollapseTimer();\n    const timer = window.setTimeout(() => {\n      setIsExpanded(false);\n      setHoveredId(null);\n      setTapExpanded(false);\n    }, collapseDelay);\n    collapseTimer.current = timer;\n  }, [clearCollapseTimer, collapseDelay, setIsExpanded]);\n\n  useEffect(() => clearCollapseTimer, [clearCollapseTimer]);\n\n  // A collapse from outside takes the labels with it, so the arm the tap that\n  // expanded the bar left behind has to go too — otherwise the next tap runs\n  // an action whose label nobody can read. Only on the way down from expanded:\n  // a controlled bar that declined to expand at all keeps its arm, which is\n  // what lets its second tap act.\n  const wasExpanded = useRef(isExpanded);\n  useEffect(() => {\n    if (wasExpanded.current && !isExpanded) setTapExpanded(false);\n    wasExpanded.current = isExpanded;\n  }, [isExpanded]);\n\n  // A finger never hovers and Safari does not focus a button on tap, so a bar a\n  // tap expanded would have nothing to close it. The tap that lands elsewhere\n  // stands in for the pointer leaving — and it is consumed rather than passed\n  // through, because the labelled bar is exactly the kind of surface people\n  // dismiss by tapping just past it, over whatever control is there.\n  useDismiss(tapExpanded && isExpanded, close, trackRef, {\n    behavior: \"consume\",\n  });\n\n  const onRootPointerEnter = (event: PointerEvent<HTMLDivElement>) => {\n    // The gesture is told about every enter, `expandOnHover` or not: it is\n    // what the matching leave is read against.\n    if (hover.enter(event) && expandOnHover) open();\n  };\n\n  const onRootPointerLeave = (event: PointerEvent<HTMLDivElement>) => {\n    if (!hover.leave(event)) return;\n    setHoveredId(null);\n    if (expandOnHover) close();\n  };\n\n  const onRootFocus = () => {\n    if (expandOnFocus) open();\n  };\n\n  const onRootBlur = (event: FocusEvent<HTMLDivElement>) => {\n    if (!event.currentTarget.contains(event.relatedTarget as Node) && expandOnFocus) {\n      close();\n    }\n  };\n\n  const activeItemId = activeId ?? items.find((item) => item.active)?.id;\n  const highlightId = hoveredId ?? activeItemId;\n\n  return (\n    <LayoutGroup id={layoutId}>\n      <motion.div\n        layout=\"size\"\n        // Pointer events, not the mouse pair: a tap fires compatibility\n        // mouseenter/mouseleave that carry no pointerType, and the bar growing\n        // under a stationary finger fired the leave before the click ever\n        // landed — so one tap expanded, collapsed and ran nothing.\n        onPointerEnter={onRootPointerEnter}\n        onPointerLeave={onRootPointerLeave}\n        onFocus={onRootFocus}\n        onBlur={onRootBlur}\n        transition={ITEM_TRANSITION}\n        className={cn(\"inline-flex max-w-full\", classNames?.root, className)}\n      >\n        <motion.div\n          ref={trackRef}\n          layout=\"size\"\n          className={cn(\n            // Labelled actions can outgrow the space the bar sits in — the pill\n            // stays inside it and scrolls its rail rather than running off the\n            // edge, where the last action is unreachable.\n            \"scrollbar-hide relative inline-flex max-w-full items-center overflow-x-auto overflow-y-hidden rounded-full border border-border bg-card/90 shadow-2xl backdrop-blur-xl\",\n            SIZE_CLASS[size],\n            classNames?.track,\n          )}\n          transition={ITEM_TRANSITION}\n        >\n          {items.map((item) => {\n            const isActive = item.active || activeId === item.id;\n            const isHighlighted = highlightId === item.id;\n\n            return (\n              <motion.button\n                key={item.id}\n                layout=\"position\"\n                type=\"button\"\n                disabled={item.disabled}\n                title={typeof item.label === \"string\" ? item.label : undefined}\n                onPointerEnter={(event: PointerEvent<HTMLButtonElement>) => {\n                  if (!hover.enter(event)) return;\n                  clearCollapseTimer();\n                  setHoveredId(item.id);\n                }}\n                onPointerDown={(event: PointerEvent<HTMLButtonElement>) => {\n                  tap.start(event, isExpanded);\n                }}\n                // A gesture the platform takes away sends no click, and a key\n                // press starts an activation that never had a pointer behind\n                // it: either one would otherwise leave the finger in place for\n                // the next click to spend.\n                onPointerCancel={tap.drop}\n                onKeyDown={tap.drop}\n                onClick={(event: MouseEvent<HTMLButtonElement>) => {\n                  event.currentTarget.blur();\n                  const gesture = tap.take();\n                  // Nothing reveals the labels to a finger, so the first tap\n                  // expands the bar and the next one runs the action. The bar\n                  // state is read from the gesture's start: a browser that\n                  // focuses the button on contact expands it mid-tap, and that\n                  // first tap would otherwise fire the action it was meant to\n                  // reveal. `tapExpanded` arms the second tap, so a controlled\n                  // bar that declines to expand still runs the action rather\n                  // than swallowing every tap.\n                  const firstTap =\n                    gesture !== null &&\n                    gesture.pointerType !== \"mouse\" &&\n                    !gesture.state &&\n                    !tapExpanded;\n                  if (firstTap && expandOnHover) {\n                    setTapExpanded(true);\n                    open();\n                    setHoveredId(item.id);\n                    return;\n                  }\n                  item.onClick?.();\n                  onAction?.(item);\n                }}\n                whileTap={reduce || item.disabled ? undefined : { scale: 0.96 }}\n                transition={ITEM_TRANSITION}\n                className={cn(\n                  \"relative isolate inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-medium text-muted-foreground outline-none transition-[color,background-color] duration-150 ease-out\",\n                  \"focus-visible:text-foreground disabled:pointer-events-none disabled:opacity-40\",\n                  isHighlighted && \"text-foreground\",\n                  ITEM_SIZE_CLASS[size],\n                  classNames?.item,\n                  isActive && classNames?.activeItem,\n                )}\n              >\n                {isHighlighted ? (\n                  <motion.span\n                    layoutId=\"action-bar-highlight\"\n                    className=\"absolute inset-0 -z-10 rounded-full bg-primary/[0.07]\"\n                    transition={ITEM_TRANSITION}\n                  />\n                ) : null}\n\n                {renderItem ? (\n                  renderItem(item, { expanded: isExpanded, active: isActive })\n                ) : (\n                  <>\n                    <span\n                      className={cn(\n                        \"inline-flex shrink-0 items-center justify-center\",\n                        ICON_SIZE_CLASS[size],\n                        classNames?.icon,\n                      )}\n                    >\n                      {item.icon}\n                    </span>\n\n                    <motion.span\n                      aria-hidden={!isExpanded}\n                      animate={\n                        reduce\n                          ? {\n                              width: isExpanded ? \"auto\" : 0,\n                              opacity: isExpanded ? 1 : 0,\n                              marginLeft: isExpanded ? 8 : 0,\n                              x: 0,\n                              filter: \"blur(0px)\",\n                            }\n                          : {\n                              width: isExpanded ? \"auto\" : 0,\n                              opacity: isExpanded ? 1 : 0,\n                              x: isExpanded ? 0 : -4,\n                              marginLeft: isExpanded ? 8 : 0,\n                              filter: isExpanded ? \"blur(0px)\" : \"blur(3px)\",\n                            }\n                      }\n                      transition={reduce ? { duration: 0 } : LABEL_TRANSITION}\n                      className={cn(\n                        \"inline-block overflow-hidden whitespace-nowrap\",\n                        classNames?.label,\n                      )}\n                    >\n                      {item.label}\n                    </motion.span>\n\n                    {item.shortcut ? (\n                      <motion.span\n                        aria-hidden={!isExpanded}\n                        animate={{\n                          width: isExpanded ? \"auto\" : 0,\n                          opacity: isExpanded ? 1 : 0,\n                          marginLeft: isExpanded ? 4 : 0,\n                        }}\n                        transition={LABEL_TRANSITION}\n                        className={cn(\n                          \"hidden overflow-hidden whitespace-nowrap text-[10px] text-muted-foreground sm:inline-block\",\n                          classNames?.shortcut,\n                        )}\n                      >\n                        {item.shortcut}\n                      </motion.span>\n                    ) : null}\n\n                    {item.badge ? (\n                      <span\n                        className={cn(\n                          \"ml-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] leading-none text-primary-foreground\",\n                          !isExpanded && \"absolute right-0.5 top-0.5\",\n                          classNames?.badge,\n                        )}\n                      >\n                        {item.badge}\n                      </span>\n                    ) : null}\n                  </>\n                )}\n              </motion.button>\n            );\n          })}\n        </motion.div>\n      </motion.div>\n    </LayoutGroup>\n  );\n}\n\nexport function useExpandableActionBar(items: ExpandableActionBarItem[]) {\n  const [expanded, setExpanded] = useState(false);\n  const [activeId, setActiveId] = useState(items[0]?.id);\n\n  const activeItem = useMemo(\n    () => items.find((item) => item.id === activeId),\n    [activeId, items],\n  );\n\n  return useMemo(\n    () => ({ expanded, setExpanded, activeId, setActiveId, activeItem }),\n    [activeId, activeItem, expanded],\n  );\n}\n",
      "type": "registry:component",
      "target": "components/motion/expandable-action-bar.tsx"
    },
    {
      "path": "lib/hooks/use-dismiss.ts",
      "content": "\"use client\";\n\nimport { type RefObject, useEffect } from \"react\";\n\n/**\n * What the dismissing gesture does to the control it landed on.\n *\n * `\"pass-through\"` is the platform norm (native popover light-dismiss): the\n * tap closes the overlay *and* activates whatever was under it. Use\n * `\"consume\"` where the open overlay sits over or beside controls that would\n * be costly to trigger by accident — the dismissal then swallows the\n * activation too, so the gesture only closes.\n */\nexport type DismissBehavior = \"pass-through\" | \"consume\";\n\nexport interface DismissOptions {\n  /** Default `\"pass-through\"`. */\n  behavior?: DismissBehavior;\n  /** Dismiss on Escape as well. Default true. */\n  escape?: boolean;\n  /** Return true for an outside target that should *not* dismiss. Must be stable. */\n  ignore?: (target: Element) => boolean;\n}\n\n/**\n * What every currently open dismiss scope counts as inside itself. A consumed\n * dismissal reads this to tell a stray gesture from one that belongs to an\n * overlay in front of it: overlays have no shared z-order to consult, but the\n * one the gesture landed in has said as much by registering it.\n */\nconst openScopes = new Set<(target: Element) => boolean>();\n\nfunction claimedByAnotherScope(\n  self: (target: Element) => boolean,\n  target: Element,\n) {\n  for (const scope of openScopes) {\n    if (scope !== self && scope(target)) return true;\n  }\n  return false;\n}\n\n// preventDefault on pointerdown does not suppress the click that follows, so\n// consuming a gesture means swallowing that click itself. The swallower\n// deliberately outlives the effect that installed it — the dismissal it\n// belongs to has already unmounted or re-rendered by the time the click lands.\n// It releases on that click, or on the next gesture if the pointer is dragged\n// away and no click ever arrives, so it can never eat a later one. A keydown\n// releases it too: a gesture that ends with neither a click nor a cancel would\n// otherwise leave it armed, and the click Enter synthesizes on some focused\n// control is not the one this dismissal was owed.\nfunction consumeActivation(source: Event) {\n  const swallow = (event: MouseEvent) => {\n    event.preventDefault();\n    event.stopPropagation();\n    release();\n  };\n  const restart = (event: Event) => {\n    if (event !== source) release();\n  };\n  const release = () => {\n    window.removeEventListener(\"click\", swallow, true);\n    window.removeEventListener(\"pointerdown\", restart, true);\n    window.removeEventListener(\"pointercancel\", restart, true);\n    window.removeEventListener(\"keydown\", release, true);\n  };\n  window.addEventListener(\"click\", swallow, true);\n  window.addEventListener(\"pointerdown\", restart, true);\n  window.addEventListener(\"pointercancel\", restart, true);\n  window.addEventListener(\"keydown\", release, true);\n}\n\n/**\n * Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`\n * for `ref` when what counts as inside isn't one element, and say so with\n * `ignore` instead.\n *\n * The pointerdown listener is capture-phase: a bubble-phase one is blinded by\n * any handler in between that stops propagation, and an overlay cannot know\n * what it is layered over. `onDismiss` and `ignore` must be stable (wrap in\n * useCallback) so the listeners aren't re-bound every render while open.\n */\nexport function useDismiss(\n  open: boolean,\n  onDismiss: () => void,\n  ref: RefObject<HTMLElement | null> | null,\n  {\n    behavior = \"pass-through\",\n    escape: dismissOnEscape = true,\n    ignore,\n  }: DismissOptions = {},\n) {\n  useEffect(() => {\n    if (!open) return;\n    const inside = (target: Element) =>\n      Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));\n    const onKey = (event: KeyboardEvent) => {\n      if (dismissOnEscape && event.key === \"Escape\") onDismiss();\n    };\n    const onPointer = (event: PointerEvent) => {\n      const target = event.target as Element | null;\n      if (!target || inside(target)) return;\n      // Outside this overlay, but inside one that is also open: the gesture is\n      // that overlay's to answer, and swallowing its click from behind would\n      // cost the user the control they actually aimed at.\n      if (behavior === \"consume\" && !claimedByAnotherScope(inside, target)) {\n        consumeActivation(event);\n      }\n      onDismiss();\n    };\n    openScopes.add(inside);\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer, true);\n    return () => {\n      openScopes.delete(inside);\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer, true);\n    };\n  }, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);\n}\n",
      "type": "registry:hook",
      "target": "lib/hooks/use-dismiss.ts"
    },
    {
      "path": "lib/hooks/use-hover-gesture.ts",
      "content": "\"use client\";\n\nimport { useMemo, useRef } from \"react\";\nimport { isHoveringPointer } from \"@/lib/touch\";\n\ninterface BoundaryEvent {\n  pointerId: number;\n  pointerType: string;\n  buttons: number;\n}\n\nexport interface HoverGesture {\n  /** True when this enter starts a hover: the pointer arrived resting, not pressing. */\n  enter: (event: BoundaryEvent) => boolean;\n  /** True when this leave ends a hover that entered as one. */\n  leave: (event: BoundaryEvent) => boolean;\n}\n\n/**\n * Pairs a surface's enter with its leave, per pointer.\n *\n * `isHoveringPointer` answers the question the *enter* asks — is this pointer\n * resting on the surface or pressing it — and both boundary cases go wrong if\n * the leave is asked the same question again:\n *\n * - A pen with no hover never rests. It arrives in contact, taps, and the spec\n *   then requires its boundary events after `pointerup`, so the leave carries\n *   `buttons: 0` and reads as a mouse gliding off. Hover teardown then undid\n *   the tap — the panel the pen had just opened closed under it.\n * - A mouse pressed on the surface and dragged off leaves with `buttons: 1`.\n *   Skipping teardown there strands the surface open: the release happens\n *   outside, and no second leave ever comes.\n *\n * So the state a hover holds is released by the pointer that took it, whatever\n * the buttons say at the boundary, and a pointer that arrived in contact never\n * took it in the first place. Contact is the exception tracked here, not\n * hover: a leave from a pointer this surface never saw enter — mounted under\n * the cursor, say — still counts, since the alternative is state with no way\n * out.\n */\nexport function useHoverGesture(): HoverGesture {\n  const contact = useRef(new Set<number>());\n\n  return useMemo(\n    () => ({\n      enter: (event) => {\n        if (isHoveringPointer(event)) {\n          contact.current.delete(event.pointerId);\n          return true;\n        }\n        contact.current.add(event.pointerId);\n        return false;\n      },\n      leave: (event) => {\n        const arrivedInContact = contact.current.delete(event.pointerId);\n        return !arrivedInContact && event.pointerType !== \"touch\";\n      },\n    }),\n    [],\n  );\n}\n",
      "type": "registry:hook",
      "target": "lib/hooks/use-hover-gesture.ts"
    },
    {
      "path": "lib/hooks/use-tap-gesture.ts",
      "content": "\"use client\";\n\nimport { useMemo, useRef } from \"react\";\n\n/** What a pointerdown recorded, read back by the click that ends its gesture. */\nexport interface TapRecord<S> {\n  /** Which input started the gesture. */\n  pointerType: string;\n  /** What the surface was showing when it started. */\n  state: S;\n}\n\nexport interface TapGesture<S> {\n  /** Record the gesture a pointerdown starts, with the state it starts in. */\n  start: (event: { pointerType: string }, state: S) => void;\n  /** Read the record and clear it. `null` when no pointer is behind this click. */\n  take: () => TapRecord<S> | null;\n  /** Drop the record: this gesture will never spend it on a click. */\n  drop: () => void;\n}\n\n/**\n * The pointer gesture behind a click, recorded where the click cannot report\n * it. A `click` carries no `pointerType` in the engines that matter, so the\n * `pointerdown` before it is the only thing that says which input activated\n * the control — and whether one did at all, since keyboard activation\n * synthesizes a click with no pointer behind it.\n *\n * State goes in with the record because a click reports that no better: a\n * browser that focuses a control on contact can open the very panel the tap\n * was meant to open, and reading \"is it open\" at click time then undoes it.\n * What the gesture started against is what it acts on.\n *\n * The record is spent by one click and dropped by everything else, because a\n * record that outlives its gesture is worse than none:\n *\n * - A scroll or an OS gesture takes the touch away — `pointercancel`, no click\n *   ever — and the finger would sit in the record until some later click.\n * - That later click is often `Enter` on a keyboard, which arrives with no\n *   pointerdown of its own and would inherit the abandoned finger. A keydown\n *   is the start of a keyboard activation and never part of a tap, so it drops\n *   the record too.\n *\n * Both ends have to be wired by the surface: `drop` on `onPointerCancel` and\n * on `onKeyDown`.\n */\nexport function useTapGesture<S>(): TapGesture<S> {\n  const record = useRef<TapRecord<S> | null>(null);\n\n  return useMemo(\n    () => ({\n      start: (event, state) => {\n        record.current = { pointerType: event.pointerType, state };\n      },\n      take: () => {\n        const spent = record.current;\n        record.current = null;\n        return spent;\n      },\n      drop: () => {\n        record.current = null;\n      },\n    }),\n    [],\n  );\n}\n",
      "type": "registry:hook",
      "target": "lib/hooks/use-tap-gesture.ts"
    },
    {
      "path": "lib/touch.ts",
      "content": "// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n",
      "type": "registry:lib",
      "target": "lib/touch.ts"
    }
  ],
  "type": "registry:component"
}
