{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"user-menu","type":"registry:ui","title":"User menu","description":"Your account, settings, theme, and sign out behind the avatar. Opens as a bottom sheet on phones.","categories":["actions"],"dependencies":["lucide-react","motion"],"registryDependencies":["https://ui.teamofsilicons.com/r/silicon-motion-tokens.json"],"files":[{"path":"registry/components/user-menu/user-menu.tsx","type":"registry:component","target":"@components/silicon-ui/user-menu/user-menu.tsx","content":"\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties, FocusEvent, KeyboardEvent, MouseEvent, PointerEvent, ReactNode, Ref } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { PanInfo, Transition, Variants } from \"motion/react\";\nimport { ChevronDown, LoaderCircle, LogOut, Monitor, Moon, Sun, SunMoon } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { useNearestItem } from \"../lib/fluid-hover\";\nimport styles from \"./user-menu.module.css\";\n\nexport type UserStatus = \"available\" | \"busy\" | \"away\";\nexport type ThemePreference = \"light\" | \"dark\" | \"system\";\n\n/** Statuses in menu order. Each one has its own dot shape as well as color: solid, barred, or hollow. */\nexport const userStatuses: { value: UserStatus; label: string }[] = [\n  { value: \"available\", label: \"Available\" },\n  { value: \"busy\", label: \"Busy\" },\n  { value: \"away\", label: \"Away\" },\n];\n\nconst themes: { value: ThemePreference; label: string; icon: ReactNode }[] = [\n  { value: \"light\", label: \"Light\", icon: <Sun size={14} strokeWidth={1.75} aria-hidden=\"true\" /> },\n  { value: \"dark\", label: \"Dark\", icon: <Moon size={14} strokeWidth={1.75} aria-hidden=\"true\" /> },\n  { value: \"system\", label: \"System\", icon: <Monitor size={14} strokeWidth={1.75} aria-hidden=\"true\" /> },\n];\n\nexport interface UserMenuUser { name: string; email: string; plan?: string; avatarSrc?: string; avatarSrcSet?: string }\nexport interface UserMenuItem { label: string; icon?: ReactNode; keys?: string[]; onSelect?: () => void }\n\n/**\n * The account menu behind a person's photo in an app's top bar. It holds a compact identity header, a short group of account\n * destinations, an inline theme switch, and sign out. On screens narrower than 640px it opens as a bottom sheet instead.\n * Theme and status choices keep the menu open; everything else closes it and returns focus to the trigger.\n * When `onSignOut` returns a promise, the item shows its progress and the menu closes once it settles.\n */\nexport interface UserMenuProps {\n  user: UserMenuUser;\n  /** Presence status. Passing `status` or `onStatusChange` shows the presence dot and an inline status switch. */\n  status?: UserStatus;\n  defaultStatus?: UserStatus;\n  onStatusChange?: (status: UserStatus) => void;\n  theme?: ThemePreference;\n  defaultTheme?: ThemePreference;\n  /** Reports the choice. Applying it to the page is up to the app. */\n  onThemeChange?: (theme: ThemePreference) => void;\n  /** Shows the inline light, dark, and system switch. */\n  showTheme?: boolean;\n  /** Account destinations with optional shortcut hints, such as [\"⌘\", \",\"]. Keep it to three or four. */\n  items?: UserMenuItem[];\n  onSignOut?: () => void | Promise<unknown>;\n  signOutKeys?: string[];\n  align?: \"start\" | \"center\" | \"end\";\n  /** Shows the name and a chevron beside the avatar from 640px up. */\n  showName?: boolean;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Renders the desktop panel in a portal on the body. Pass false to keep it inside the trigger's wrapper. */\n  portal?: boolean;\n  /** The trigger button, for returning focus after the menu is swapped out. */\n  ref?: Ref<HTMLButtonElement>;\n  className?: string;\n}\n\ntype Highlight = { top: number; height: number; tone?: string; glide: boolean };\ntype OpenReason = \"first\" | \"last\" | \"pointer\" | null;\n\nconst compactQuery = \"(max-width: 639px)\";\nconst subscribeCompact = (change: () => void) => { const query = window.matchMedia(compactQuery); query.addEventListener(\"change\", change); return () => query.removeEventListener(\"change\", change); };\nconst subscribeNothing = () => () => {};\nconst statusLabel = (status: UserStatus) => userStatuses.find(option => option.value === status)?.label ?? status;\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\nconst exitEase = [0.4, 0, 1, 1] as [number, number, number, number];\n\n/** The panel grows out of the trigger on a critically damped spring; rows follow a beat later. Closing is a short fade. */\nconst panelMotion: Variants = {\n  closed: { opacity: 0, scale: .94 },\n  open: { opacity: 1, scale: 1, transition: { type: \"spring\", visualDuration: motionTokens.spring.moderate.visualDuration, bounce: 0, opacity: { duration: motionTokens.duration.fast, ease: enter }, delayChildren: .03, staggerChildren: .016 } },\n  exit: { opacity: 0, scale: .97, transition: { duration: motionTokens.duration.instant, ease: exitEase } },\n};\nconst sheetMotion: Variants = {\n  closed: { y: \"100%\" },\n  open: { y: 0, transition: { ...motionTokens.spring.smooth, visualDuration: motionTokens.spring.slow.visualDuration, delayChildren: .06, staggerChildren: .02 } },\n  exit: { y: \"100%\", transition: { duration: motionTokens.duration.exit, ease: exitEase } },\n};\nconst stillMotion: Variants = { closed: { opacity: 0 }, open: { opacity: 1, transition: { duration: motionTokens.duration.instant } }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };\nconst rowMotion: Variants = { closed: { opacity: 0, y: 3 }, open: { opacity: 1, y: 0, transition: { duration: motionTokens.duration.exit, ease: enter } } };\n\n/** A presence mark that morphs between statuses: the color crossfades while a hole opens for away and a bar slides in for busy. */\nexport function PresenceDot({ status, className }: { status: UserStatus | \"offline\"; className?: string }) {\n  return <span className={[styles.dot, className].filter(Boolean).join(\" \")} data-status={status} aria-hidden=\"true\" />;\n}\n\nfunction Portrait({ user }: { user: UserMenuUser }) {\n  const [failed, setFailed] = useState<string>();\n  if (!user.avatarSrc || failed === user.avatarSrc) {\n    const initials = user.name.trim().split(/\\s+/).slice(0, 2).map(part => part[0]?.toUpperCase()).join(\"\");\n    return <span className={`${styles.portrait} ${styles.initials}`} aria-hidden=\"true\">{initials}</span>;\n  }\n  // eslint-disable-next-line @next/next/no-img-element -- registry components stay framework agnostic; pass optimized URLs as avatarSrc and avatarSrcSet.\n  return <img className={styles.portrait} src={user.avatarSrc} srcSet={user.avatarSrcSet} alt=\"\" decoding=\"async\" draggable={false} onError={() => setFailed(user.avatarSrc)} />;\n}\n\nfunction Face({ user, status, size }: { user: UserMenuUser; status?: UserStatus; size: \"sm\" | \"md\" }) {\n  return <span className={styles.face} data-size={size}><Portrait user={user} />{status && <PresenceDot status={status} />}</span>;\n}\n\n/** Changing text rises out of a small blur while the old text lifts away. */\nfunction Rise({ text, reduced }: { text: string; reduced: boolean }) {\n  return <AnimatePresence mode=\"popLayout\" initial={false}>\n    <motion.span key={text} className={styles.rise} initial={reduced ? { opacity: 0 } : { opacity: 0, y: \"0.35em\", filter: `blur(${motionTokens.blur.subtle}px)` }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n      exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: \"-0.35em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: standard } }}\n      transition={reduced ? { duration: 0 } : { duration: motionTokens.duration.standard, ease: enter }}>{text}</motion.span>\n  </AnimatePresence>;\n}\n\nfunction Keys({ keys }: { keys: string[] }) {\n  return <kbd className={styles.keys} aria-hidden=\"true\">{keys.map((key, index) => <kbd key={`${key}-${index}`} className={styles.key}>{key}</kbd>)}</kbd>;\n}\n\n/** A compact inline switch. It is one stop in the menu: up and down pass over it, left and right choose within it. */\nfunction Segmented<T extends string>({ label, icon, value, options, onChange, variants }: { label: string; icon: ReactNode; value: T; options: { value: T; label: string; icon: ReactNode }[]; onChange: (value: T) => void; variants?: Variants }) {\n  const index = Math.max(0, options.findIndex(option => option.value === value));\n  return <motion.div className={styles.row} data-stop=\"group\" data-label={label} variants={variants}>\n    <span className={styles.icon} aria-hidden=\"true\">{icon}</span>\n    <span className={styles.rowLabel} aria-hidden=\"true\">{label}</span>\n    <div className={styles.segments} role=\"group\" aria-label={label} style={{ \"--index\": index, \"--count\": options.length } as CSSProperties}>\n      <span className={styles.thumb} aria-hidden=\"true\" />\n      {options.map(option => <button key={option.value} type=\"button\" role=\"menuitemradio\" tabIndex={-1} aria-checked={option.value === value} aria-label={option.label} title={option.label}\n        className={styles.segment} onClick={() => onChange(option.value)}>{option.icon}</button>)}\n    </div>\n  </motion.div>;\n}\n\nconst getStops = (root: HTMLElement | null) => Array.from(root?.querySelectorAll<HTMLElement>(\"[data-stop]\") ?? []);\nfunction focusStop(stop: HTMLElement | undefined) {\n  if (!stop) return;\n  const target = stop.dataset.stop === \"group\" ? stop.querySelector<HTMLElement>('[aria-checked=\"true\"]') ?? stop.querySelector<HTMLElement>(\"button\") : stop;\n  target?.focus({ preventScroll: true });\n}\n\nexport function UserMenu({ user, status: statusProp, defaultStatus = \"available\", onStatusChange, theme: themeProp, defaultTheme = \"system\", onThemeChange, showTheme = true, items = [], onSignOut, signOutKeys, align = \"end\", showName = false, open: openProp, defaultOpen = false, onOpenChange, portal = true, ref, className }: UserMenuProps) {\n  const id = useId();\n  const menuId = `${id}-menu`;\n  const triggerId = `${id}-trigger`;\n  const reduced = !!useReducedMotion();\n  const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);\n  const compact = useSyncExternalStore(subscribeCompact, () => window.matchMedia(compactQuery).matches, () => false);\n  const [innerOpen, setInnerOpen] = useState(defaultOpen);\n  const [innerStatus, setInnerStatus] = useState(defaultStatus);\n  const [innerTheme, setInnerTheme] = useState(defaultTheme);\n  const [signingOut, setSigningOut] = useState(false);\n  const [highlight, setHighlight] = useState<Highlight | null>(null);\n  const open = openProp ?? innerOpen;\n  const showStatus = statusProp !== undefined || onStatusChange !== undefined;\n  const status = statusProp ?? innerStatus;\n  const theme = themeProp ?? innerTheme;\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const triggerRef = useRef<HTMLButtonElement | null>(null);\n  const surfaceRef = useRef<HTMLDivElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const reason = useRef<OpenReason>(null);\n  const typed = useRef({ text: \"\", timer: 0 });\n  const mounted = useRef(true);\n  const pending = useRef(false);\n  const sheet = hydrated && compact;\n  const inline = !portal && !sheet;\n\n  useEffect(() => { mounted.current = true; const current = typed.current; return () => { mounted.current = false; window.clearTimeout(current.timer); }; }, []);\n\n  function setTriggerRef(node: HTMLButtonElement | null) {\n    triggerRef.current = node;\n    if (typeof ref === \"function\") ref(node);\n    else if (ref) (ref as { current: HTMLButtonElement | null }).current = node;\n  }\n\n  function setOpen(next: boolean, why: OpenReason = null) {\n    reason.current = next ? why : null;\n    if (next) setSigningOut(pending.current);\n    setHighlight(null);\n    setInnerOpen(next);\n    onOpenChange?.(next);\n  }\n  function close(returnFocus: boolean) {\n    setOpen(false);\n    if (returnFocus) triggerRef.current?.focus({ preventScroll: true });\n  }\n\n  // Focus moves in only when a person opened the menu, so a menu that starts open never steals focus or scrolls the page.\n  useEffect(() => {\n    if (!open) return;\n    const why = reason.current;\n    reason.current = null;\n    if (!why) return;\n    const frame = requestAnimationFrame(() => {\n      const stops = getStops(listRef.current);\n      if (why === \"first\") focusStop(stops[0]);\n      else if (why === \"last\") focusStop(stops.at(-1));\n      else surfaceRef.current?.focus({ preventScroll: true });\n    });\n    return () => cancelAnimationFrame(frame);\n  }, [open, sheet]);\n\n  // The panel is placed against the trigger and scales from the avatar's center, flipping above when there is no room below.\n  useLayoutEffect(() => {\n    if (!open || sheet) return;\n    const place = () => {\n      const panel = surfaceRef.current, trigger = triggerRef.current;\n      if (!panel || !trigger) return;\n      const rect = trigger.getBoundingClientRect();\n      const width = panel.offsetWidth, height = panel.offsetHeight;\n      let left: number, top: number;\n      if (inline) {\n        const root = rootRef.current?.getBoundingClientRect();\n        left = (root?.left ?? 0) + panel.offsetLeft;\n        top = (root?.top ?? 0) + panel.offsetTop;\n      } else {\n        const wanted = align === \"start\" ? rect.left : align === \"center\" ? rect.left + rect.width / 2 - width / 2 : rect.right - width;\n        left = Math.min(Math.max(12, wanted), window.innerWidth - width - 12);\n        const below = rect.bottom + 8;\n        top = below + height > window.innerHeight - 12 && rect.top - 8 - height > 12 ? rect.top - 8 - height : below;\n        panel.style.left = `${left}px`;\n        panel.style.top = `${top}px`;\n      }\n      const faceCenter = rect.left + Math.min(rect.width, 40) / 2;\n      const originX = align === \"end\" && rect.width > 40 ? rect.right - 20 - left : faceCenter - left;\n      panel.style.setProperty(\"--origin-x\", `${Math.min(Math.max(0, originX), width)}px`);\n      panel.style.setProperty(\"--origin-y\", top < rect.top ? `${height}px` : \"0px\");\n    };\n    place();\n    if (inline) return;\n    window.addEventListener(\"resize\", place);\n    window.addEventListener(\"scroll\", place, true);\n    return () => { window.removeEventListener(\"resize\", place); window.removeEventListener(\"scroll\", place, true); };\n  }, [open, sheet, inline, align]);\n\n  // Outside presses close the panel. The sheet has its own scrim.\n  useEffect(() => {\n    if (!open || sheet) return;\n    const onPointerDown = (event: globalThis.PointerEvent) => {\n      const target = event.target as Node;\n      if (surfaceRef.current?.contains(target) || triggerRef.current?.contains(target)) return;\n      reason.current = null;\n      setHighlight(null);\n      setInnerOpen(false);\n      onOpenChange?.(false);\n    };\n    document.addEventListener(\"pointerdown\", onPointerDown, true);\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown, true);\n  }, [open, sheet, onOpenChange]);\n\n  // The page behind the sheet stays put.\n  useEffect(() => {\n    if (!open || !sheet) return;\n    const root = document.documentElement;\n    const previous = root.style.overflow;\n    root.style.overflow = \"hidden\";\n    return () => { root.style.overflow = previous; };\n  }, [open, sheet]);\n\n  function changeStatus(value: UserStatus) { setInnerStatus(value); onStatusChange?.(value); }\n  function changeTheme(value: ThemePreference) { setInnerTheme(value); onThemeChange?.(value); }\n\n  function signOut() {\n    if (pending.current) return;\n    const result = onSignOut?.();\n    if (!result || typeof result.then !== \"function\") { close(true); return; }\n    // Async sign out keeps the menu open with its progress on the item, then closes once it settles.\n    pending.current = true;\n    setSigningOut(true);\n    const done = () => { pending.current = false; if (mounted.current) close(false); };\n    result.then(done, done);\n  }\n\n  function onListFocus(event: FocusEvent<HTMLDivElement>) {\n    const stop = event.target instanceof HTMLElement ? event.target.closest<HTMLElement>(\"[data-stop]\") : null;\n    if (!stop) { setHighlight(null); return; }\n    setHighlight(current => ({ top: stop.offsetTop, height: stop.offsetHeight, tone: stop.dataset.tone, glide: current !== null }));\n  }\n\n  // Fluid hover: the mouse focuses the nearest row, separators and padding included, so the highlight (which follows focus)\n  // lights it and the keyboard carries on from there. A click in a gap picks that row.\n  const nearest = useNearestItem<HTMLDivElement>({ items: '[data-stop=\"item\"]', onPick: item => { if (document.activeElement !== item) item.focus({ preventScroll: true }); } });\n  // The inline switches are not rows, so the pointer over them releases the highlight instead of dragging it along.\n  function onListPointerMove(event: PointerEvent<HTMLDivElement>) {\n    if (event.pointerType === \"touch\") return;\n    const group = event.target instanceof HTMLElement ? event.target.closest<HTMLElement>('[data-stop=\"group\"]') : null;\n    if (!group) { nearest.onPointerMove(event); return; }\n    if (!highlight || group.contains(document.activeElement)) return;\n    setHighlight(null);\n    surfaceRef.current?.focus({ preventScroll: true });\n  }\n  function onListPointerLeave(event: PointerEvent<HTMLElement>) {\n    if (event.pointerType === \"touch\") return;\n    setHighlight(null);\n    surfaceRef.current?.focus({ preventScroll: true });\n  }\n\n  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    const stops = getStops(listRef.current);\n    const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n    const current = active?.closest<HTMLElement>(\"[data-stop]\") ?? null;\n    const index = current ? stops.indexOf(current) : -1;\n    const step = (to: HTMLElement | undefined) => { event.preventDefault(); focusStop(to); };\n    switch (event.key) {\n      case \"ArrowDown\": return step(stops[(index + 1) % stops.length]);\n      case \"ArrowUp\": return step(stops[index <= 0 ? stops.length - 1 : index - 1]);\n      case \"Home\": return step(stops[0]);\n      case \"End\": return step(stops.at(-1));\n      case \"Escape\": case \"Tab\": event.preventDefault(); return close(true);\n      case \"ArrowLeft\": case \"ArrowRight\": {\n        if (current?.dataset.stop !== \"group\") return;\n        event.preventDefault();\n        const segments = Array.from(current.querySelectorAll<HTMLButtonElement>('[role=\"menuitemradio\"]'));\n        const at = Math.max(0, segments.findIndex(segment => segment.getAttribute(\"aria-checked\") === \"true\"));\n        const next = segments[(at + (event.key === \"ArrowRight\" ? 1 : -1) + segments.length) % segments.length];\n        next?.focus({ preventScroll: true });\n        next?.click();\n        return;\n      }\n    }\n    if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey || event.key === \" \") return;\n    // Typeahead: letters jump to the next row whose label starts with what was typed.\n    const memory = typed.current;\n    window.clearTimeout(memory.timer);\n    memory.text += event.key.toLowerCase();\n    memory.timer = window.setTimeout(() => { memory.text = \"\"; }, 500);\n    const ordered = [...stops.slice(index + 1), ...stops.slice(0, index + 1)];\n    const search = memory.text.length > 1 && current?.dataset.label?.toLowerCase().startsWith(memory.text) ? [current] : ordered;\n    const match = search.find(stop => stop.dataset.label?.toLowerCase().startsWith(memory.text));\n    if (match) step(match);\n  }\n\n  function onTriggerClick(event: MouseEvent<HTMLButtonElement>) {\n    if (open) { close(false); return; }\n    setOpen(true, event.detail === 0 ? \"first\" : \"pointer\");\n  }\n  function onTriggerKeyDown(event: KeyboardEvent<HTMLButtonElement>) {\n    if (event.key !== \"ArrowDown\" && event.key !== \"ArrowUp\") return;\n    event.preventDefault();\n    setOpen(true, event.key === \"ArrowDown\" ? \"first\" : \"last\");\n  }\n\n  function onDragEnd(_: unknown, info: PanInfo) {\n    if (info.offset.y > 80 || info.velocity.y > 500) close(true);\n  }\n\n  const glide: Transition = highlight?.glide && !reduced ? motionTokens.spring.snappy : { duration: 0 };\n  const row = reduced ? undefined : rowMotion;\n  const menuProps = { id: menuId, role: \"menu\", \"aria-labelledby\": triggerId, tabIndex: -1, onKeyDown };\n\n  const content = <>\n    <motion.div className={styles.header} variants={row}>\n      <Face user={user} status={showStatus ? status : undefined} size=\"md\" />\n      <div className={styles.identity}>\n        <div className={styles.nameRow}><span className={styles.name}>{user.name}</span>{user.plan && <span className={styles.plan}>{user.plan}</span>}</div>\n        <span className={styles.email} title={user.email}>{user.email}</span>\n      </div>\n    </motion.div>\n    <div ref={listRef} className={styles.list} onFocus={onListFocus} onPointerMove={onListPointerMove} onClick={nearest.onClick} onPointerLeave={onListPointerLeave}>\n      <motion.span className={styles.highlight} data-tone={highlight?.tone} aria-hidden=\"true\" initial={false}\n        animate={highlight ? { y: highlight.top, height: highlight.height, opacity: 1 } : { opacity: 0 }}\n        transition={{ default: glide, opacity: { duration: reduced ? 0 : motionTokens.duration.instant } }} />\n      {items.length > 0 && <>\n        <div className={styles.separator} role=\"separator\" />\n        {items.map(item => <motion.button key={item.label} type=\"button\" role=\"menuitem\" tabIndex={-1} className={styles.item} data-stop=\"item\" data-label={item.label} variants={row}\n          onClick={() => { close(true); item.onSelect?.(); }}>\n          <span className={styles.icon} aria-hidden=\"true\">{item.icon}</span>\n          <span className={styles.itemLabel}>{item.label}</span>\n          {item.keys && <Keys keys={item.keys} />}\n        </motion.button>)}\n      </>}\n      {(showTheme || showStatus) && <>\n        <div className={styles.separator} role=\"separator\" />\n        {showStatus && <Segmented label=\"Status\" icon={<PresenceDot status={status} />} value={status} onChange={changeStatus}\n          options={userStatuses.map(option => ({ ...option, icon: <PresenceDot status={option.value} /> }))} variants={row} />}\n        {showTheme && <Segmented label=\"Theme\" icon={<SunMoon size={16} strokeWidth={1.75} />} value={theme} onChange={changeTheme} options={themes} variants={row} />}\n      </>}\n      <div className={styles.separator} role=\"separator\" />\n      <motion.button type=\"button\" role=\"menuitem\" tabIndex={-1} className={styles.item} data-stop=\"item\" data-tone=\"danger\" data-label=\"Sign out\" variants={row}\n        aria-busy={signingOut || undefined} onClick={signOut}>\n        <span className={styles.icon} aria-hidden=\"true\">{signingOut ? <LoaderCircle className={styles.spinner} size={16} strokeWidth={1.75} /> : <LogOut size={16} strokeWidth={1.75} />}</span>\n        <span className={styles.itemLabel}><Rise text={signingOut ? \"Signing out\" : \"Sign out\"} reduced={reduced} /></span>\n        {signOutKeys && <Keys keys={signOutKeys} />}\n      </motion.button>\n    </div>\n  </>;\n\n  const panel = <AnimatePresence>\n    {open && !sheet && <motion.div key=\"panel\" ref={surfaceRef} {...menuProps} className={styles.panel} data-inline={inline || undefined} data-align={align}\n      variants={reduced ? stillMotion : panelMotion} initial=\"closed\" animate=\"open\" exit=\"exit\">\n      {content}\n    </motion.div>}\n  </AnimatePresence>;\n\n  const bottomSheet = <AnimatePresence>\n    {open && sheet && <motion.div key=\"scrim\" className={styles.scrim} aria-hidden=\"true\" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}\n      transition={{ duration: reduced ? .1 : .2, ease: standard }} onClick={() => close(true)} />}\n    {open && sheet && <motion.div key=\"sheet\" ref={surfaceRef} {...menuProps} className={`${styles.panel} ${styles.sheet}`} aria-modal=\"true\"\n      variants={reduced ? stillMotion : sheetMotion} initial=\"closed\" animate=\"open\" exit=\"exit\"\n      drag={reduced ? false : \"y\"} dragConstraints={{ top: 0, bottom: 0 }} dragElastic={{ top: .04, bottom: .9 }} dragMomentum={false} onDragEnd={onDragEnd}>\n      <span className={styles.handle} aria-hidden=\"true\" />\n      {content}\n    </motion.div>}\n  </AnimatePresence>;\n\n  return <span ref={rootRef} className={styles.root}>\n    <button ref={setTriggerRef} id={triggerId} type=\"button\" className={[styles.trigger, className].filter(Boolean).join(\" \")} data-state={open ? \"open\" : \"closed\"} data-name={showName || undefined}\n      aria-haspopup=\"menu\" aria-expanded={open} aria-controls={open ? menuId : undefined} aria-label={`Account menu, ${user.name}, ${user.name.trim().split(/\\s+/).slice(0, 2).map(part => part[0]?.toUpperCase()).join(\"\")}${showStatus ? `, ${statusLabel(status)}` : \"\"}`}\n      onClick={onTriggerClick} onKeyDown={onTriggerKeyDown}>\n      <Face user={user} status={showStatus ? status : undefined} size=\"sm\" />\n      {showName && <><span className={styles.triggerName}>{user.name}</span><ChevronDown className={styles.chevron} size={16} strokeWidth={1.75} aria-hidden=\"true\" /></>}\n    </button>\n    {sheet ? createPortal(bottomSheet, document.body) : inline ? panel : hydrated ? createPortal(panel, document.body) : null}\n  </span>;\n}\n\nexport default UserMenu;\n"},{"path":"registry/components/user-menu/user-menu.module.css","type":"registry:component","target":"@components/silicon-ui/user-menu/user-menu.module.css","content":".root { position: relative; display: inline-flex; flex: none; vertical-align: middle; }\n\n/* The trigger anchors the menu, so it answers hover and press with color, never with scale. */\n.trigger { --presence-surface: var(--user-menu-surface, var(--surface)); position: relative; display: inline-flex; height: 40px; flex: none; align-items: center; gap: var(--space-related); margin: 0; padding: var(--space-tight); border: 0; border-radius: var(--radius-full); corner-shape: round; background: transparent; color: var(--foreground); font: inherit; font-family: var(--font-body); font-size: var(--text-sm); letter-spacing: var(--tracking-body); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard); }\n.trigger[data-name] { padding-right: var(--space-group); }\n@media (hover: hover) and (pointer: fine) { .trigger:hover { --presence-surface: var(--surface-muted); background: var(--surface-muted); } }\n.trigger[data-state=\"open\"] { --presence-surface: var(--surface-muted); background: var(--surface-muted); }\n.trigger:active { --presence-surface: color-mix(in oklab, var(--surface-muted), var(--foreground) 8%); background: color-mix(in oklab, var(--surface-muted), var(--foreground) 8%); }\n.triggerName { max-width: 11rem; overflow: hidden; font-weight: 500; line-height: 1; text-overflow: ellipsis; white-space: nowrap; }\n.chevron { flex: none; margin-left: calc(var(--space-hair) * -1); color: var(--text-muted); transition: transform var(--duration-spring) var(--ease-spring), color var(--duration-fast) var(--ease-standard); }\n.trigger:hover .chevron, .trigger[data-state=\"open\"] .chevron { color: var(--foreground); }\n.trigger[data-state=\"open\"] .chevron { transform: rotate(180deg); }\n@media (max-width: 639px) {\n  .trigger[data-name] { padding-right: var(--space-tight); }\n  .triggerName, .chevron { display: none; }\n}\n\n.face { position: relative; display: inline-grid; width: var(--face-size); height: var(--face-size); flex: none; }\n.face[data-size=\"sm\"] { --face-size: 32px; --dot-size: 11px; }\n.face[data-size=\"md\"] { --face-size: 34px; --dot-size: 10px; }\n.portrait { display: block; width: 100%; height: 100%; border-radius: var(--avatar-radius); corner-shape: var(--avatar-corner-shape); background: var(--surface-muted); object-fit: cover; object-position: 50% 15%; outline: 1px solid color-mix(in oklab, var(--foreground) 9%, transparent); outline-offset: -1px; user-select: none; }\n.initials { display: grid; place-items: center; color: var(--text-secondary); font-size: calc(var(--face-size) * .36); font-weight: 500; line-height: 1; }\n.face .dot { position: absolute; right: -1px; bottom: -1px; box-shadow: 0 0 0 2px var(--presence-surface, var(--surface)); }\n\n/* One mark, three shapes: solid for available, a bar for busy, a ring for away. Color and shape change together, so status never rests on color alone. */\n.dot { --dot-color: var(--success); position: relative; display: inline-block; width: var(--dot-size, 10px); height: var(--dot-size, 10px); flex: none; border-radius: var(--radius-full); corner-shape: round; background: var(--dot-color); transition: background-color var(--duration-standard) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }\n.dot::before, .dot::after { content: \"\"; position: absolute; inset: 0; margin: auto; background: var(--presence-surface, var(--surface)); transition: transform var(--duration-spring) var(--ease-spring), background-color var(--duration-fast) var(--ease-standard); }\n.dot::before { width: 46%; height: 46%; border-radius: var(--radius-full); corner-shape: round; transform: scale(0); }\n.dot::after { width: 58%; height: 22%; min-height: 1.5px; border-radius: var(--radius-full); corner-shape: round; transform: scaleX(0); }\n.dot[data-status=\"busy\"] { --dot-color: var(--danger); }\n.dot[data-status=\"busy\"]::after { transform: scaleX(1); }\n.dot[data-status=\"away\"] { --dot-color: var(--warning); }\n.dot[data-status=\"away\"]::before, .dot[data-status=\"offline\"]::before { transform: scale(1); }\n.dot[data-status=\"offline\"] { --dot-color: var(--text-muted); }\n\n/* The panel scales out of the avatar it belongs to; the component sets the origin once it is placed. */\n.panel { --presence-surface: var(--surface-raised); --row: 32px; --inset: 4px; --menu-radius: var(--radius-detail); --menu-line: color-mix(in oklab, var(--foreground) 9%, transparent); position: fixed; top: 0; left: 0; z-index: var(--layer-dropdown); box-sizing: border-box; width: 15.5rem; max-width: calc(100vw - 24px); padding: var(--inset); border: .5px solid var(--border); border-radius: var(--menu-radius); background: var(--surface-raised); box-shadow: var(--shadow-floating); color: var(--foreground); font-family: var(--font-body); letter-spacing: -.01em; line-height: 1.3; text-align: left; outline: none; transform-origin: var(--origin-x, 100%) var(--origin-y, 0px); }\n.panel[data-inline] { position: absolute; top: calc(100% + var(--space-related)); left: auto; }\n.panel[data-inline][data-align=\"end\"] { right: 0; }\n.panel[data-inline][data-align=\"start\"] { left: 0; }\n.panel[data-inline][data-align=\"center\"] { left: 50%; margin-left: calc(calc(var(--space-screen) + var(--space-page)) * -1); }\n\n.header { display: flex; align-items: center; gap: var(--space-group); padding: var(--space-related) var(--space-related) var(--space-related); }\n.identity { display: grid; min-width: 0; flex: 1; gap: var(--space-hair); }\n/* The name keeps its room; a long plan or role shrinks and truncates first. */\n.nameRow { display: flex; min-width: 0; align-items: center; gap: var(--space-related); }\n.name { min-width: 0; flex: 0 1 auto; overflow: hidden; color: var(--foreground); font-size: var(--text-sm); font-weight: 500; letter-spacing: -.012em; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; }\n.plan { display: inline-block; min-width: 0; max-width: 55%; flex: 0 100 auto; overflow: hidden; height: 17px; padding: 0 var(--space-tight); border: .5px solid var(--menu-line); border-radius: var(--radius-detail); corner-shape: var(--corner-shape); background: color-mix(in oklab, var(--foreground) 4%, transparent); color: var(--text-secondary); font-size: var(--text-xs); font-weight: 500; letter-spacing: -.005em; line-height: 16px; text-overflow: ellipsis; white-space: nowrap; }\n.email { overflow: hidden; color: var(--text-muted); font-size: var(--text-xs); letter-spacing: -.008em; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; }\n\n.list { position: relative; }\n.separator { height: .5px; margin: var(--inset) calc(var(--inset) * -1); background: var(--menu-line); }\n@media (max-resolution: 1dppx) { .separator { height: 1px; background: color-mix(in oklab, var(--foreground) 6%, transparent); } }\n\n/* One highlight follows the pointer and the keyboard. It is the only hover treatment in the menu. */\n.highlight { position: absolute; top: 0; right: 0; left: 0; border-radius: var(--radius-detail); corner-shape: var(--corner-shape); background: color-mix(in oklab, var(--foreground) 6%, transparent); opacity: 0; pointer-events: none; }\n\n.item { position: relative; display: flex; width: 100%; height: var(--row); box-sizing: border-box; align-items: center; gap: var(--space-group); margin: 0; padding: 0 var(--space-related); border: 0; border-radius: var(--radius-detail); corner-shape: var(--corner-shape); background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); text-align: left; cursor: pointer; outline: none; user-select: none; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); }\n.icon { display: inline-grid; width: 16px; flex: none; place-items: center; color: var(--text-secondary); transition: color var(--duration-fast) var(--ease-standard); }\n.item:focus .icon { color: var(--foreground); }\n.itemLabel { position: relative; display: inline-flex; min-width: 0; flex: 1; }\n.rise { display: inline-block; white-space: nowrap; }\n/* Sign out is not destructive, so it stays neutral like every other row. */\n@media (hover: none) { .item:active { background: var(--surface-muted); } }\n\n.keys { display: inline-flex; flex: none; align-items: center; gap: 1px; margin-left: auto; color: var(--text-muted); font-family: var(--font-body); font-size: var(--text-xs); letter-spacing: 0; opacity: .8; font-variant-numeric: tabular-nums; }\n.key { display: inline-block; min-width: 1.05em; font: inherit; text-align: center; }\n\n.row { position: relative; display: flex; height: 36px; align-items: center; gap: var(--space-group); padding: 0 var(--space-tight) 0 var(--space-related); }\n.rowLabel { flex: 1; color: var(--foreground); font-size: var(--text-sm); }\n.row .icon .dot { --dot-size: 10px; }\n/* The thumb slides under the icons; the group owns the stacking context. */\n.segments { --segment: 28px; position: relative; isolation: isolate; display: inline-grid; height: 26px; flex: none; grid-auto-columns: var(--segment); grid-auto-flow: column; padding: var(--space-hair); border-radius: var(--radius-detail); corner-shape: var(--corner-shape); background: color-mix(in oklab, var(--foreground) 5%, transparent); }\n.thumb { position: absolute; z-index: -1; top: var(--space-hair); bottom: var(--space-hair); left: var(--space-hair); width: var(--segment); border-radius: var(--radius-detail); corner-shape: var(--corner-shape); background: var(--surface-raised); box-shadow: var(--shadow-thumb), 0 0 0 .5px var(--menu-line); transform: translateX(calc(var(--index) * 100%)); transition: transform var(--duration-spring) var(--ease-spring); }\n:global([data-theme=\"dark\"]) .thumb { background: color-mix(in oklab, var(--foreground) 20%, var(--surface-raised)); box-shadow: none; }\n.segment { display: grid; place-items: center; margin: 0; padding: 0; border: 0; border-radius: var(--radius-detail); corner-shape: var(--corner-shape); background: transparent; color: var(--text-muted); cursor: pointer; outline: none; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); }\n.segment .dot { --dot-size: 9px; --presence-surface: var(--surface-raised); }\n@media (hover: hover) and (pointer: fine) { .segment:hover { color: var(--text-secondary); } }\n.segment[aria-checked=\"true\"] { color: var(--foreground); }\n\n.spinner { animation: user-menu-spin .8s linear infinite; }\n@keyframes user-menu-spin { to { transform: rotate(360deg); } }\n\n/* Below 640px the same content rises as a bottom sheet with larger rows. */\n.scrim { position: fixed; inset: 0; z-index: var(--layer-overlay); background: var(--scrim); -webkit-tap-highlight-color: transparent; }\n.sheet { --row: 46px; --inset: 10px; position: fixed; top: auto; right: 0; bottom: 0; left: 0; z-index: var(--layer-dialog); width: auto; max-width: none; max-height: 88dvh; overflow-y: auto; overscroll-behavior: contain; padding: var(--space-related) var(--inset) calc(var(--space-group) + env(safe-area-inset-bottom)); border-bottom: 0; border-radius: var(--radius-container) var(--radius-container) 0 0; corner-shape: var(--corner-shape); }\n.handle { display: block; width: 36px; height: 5px; margin: 0 auto var(--space-group); border-radius: var(--radius-full); corner-shape: round; background: var(--border-strong); cursor: grab; }\n.sheet .header { padding: var(--space-tight) var(--space-related) var(--space-group); }\n.sheet .item, .sheet .rowLabel { font-size: var(--text-base); }\n.sheet .row { height: 52px; }\n.sheet .segments { --segment: 40px; height: 34px; }\n.sheet .keys { display: none; }\n\n@media (prefers-reduced-motion: reduce) {\n  .trigger, .chevron, .dot, .dot::before, .dot::after, .segment, .thumb, .item, .icon { transition: none; }\n  .spinner { animation-duration: 2.4s; }\n}\n"},{"path":"lib/fluid-hover.tsx","type":"registry:lib","target":"@components/silicon-ui/lib/fluid-hover.tsx","content":"\"use client\";\n\n/**\n * Fluid hover: in a set of items (a menu, a list, tabs, a grid), the item nearest the pointer is lit, even while the\n * pointer is in a gap, the padding or past the last item, and one highlight springs between items. A click in a gap goes\n * to the lit item, so what is lit is what a click hits. A new pointer entry fades the highlight in where it lands instead\n * of sliding it over from the last item. The keyboard moves it without travel, and reduced motion keeps the fade but\n * drops the travel.\n *\n *   const list = useRef<HTMLUListElement>(null);\n *   const hover = useFluidHover(list, { items: \"[data-item]\" });\n *   <ul ref={list} className={styles.list} {...hover.handlers}>      // .list { position: relative }\n *     <FluidHoverHighlight hover={hover} className={styles.highlight} />\n *     …items…\n *   </ul>\n *\n * Adapted from Fluid Functionalism's use-fluid-hover and fluid-hover-highlight by Micka Touillaud (MIT),\n * https://github.com/mickadesign/fluid-functionalism. Items are found by a selector instead of registering themselves,\n * so a component adopts it without changing its items.\n */\nimport { useEffect, useEffectEvent, useRef, useState } from \"react\";\nimport type { FocusEvent, MouseEvent, PointerEvent, RefObject } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { motionTokens } from \"./motion-tokens\";\n\n/** An item's box in the container's own layout space (unaffected by transforms or scrolling). */\nexport interface FluidRect { top: number; left: number; width: number; height: number }\n\nexport interface FluidHoverState {\n  /** Where the highlight sits, or null when nothing is lit. */\n  rect: FluidRect | null;\n  /** The lit item. */\n  item: HTMLElement | null;\n  /** False when the highlight should jump (keyboard, a resize) rather than travel. */\n  glide: boolean;\n  /** Steps on each new pointer entry, so the highlight fades in where it lands. */\n  session: number;\n}\n\nexport interface FluidHoverOptions {\n  /** Selector for the items inside the container. */\n  items: string;\n  /** \"y\" for lists (default), \"x\" for a row of tabs, \"xy\" for grids. */\n  axis?: \"x\" | \"y\" | \"xy\";\n  /** Items the highlight skips (and a gap click never activates). Defaults to disabled or aria-disabled items. */\n  isItemDisabled?: (item: HTMLElement) => boolean;\n  /** \"skip\" (default) lights the nearest enabled item even over a disabled one; \"clear\" lights nothing over a disabled item (a calendar's unavailable days). */\n  disabled?: \"skip\" | \"clear\";\n  /** Route a click in a gap to the lit item. On by default; turn it off where empty space should stay inert. */\n  gapClick?: boolean;\n  /** Light the item that receives keyboard focus. On by default. */\n  followFocus?: boolean;\n}\n\nconst defaultDisabled = (item: HTMLElement) =>\n  item.matches(\":disabled, [aria-disabled='true'], [data-disabled]\");\n\n/** Controls that sit between items keep their own clicks. */\nconst CONTROL = \"input, textarea, select, button, a, summary, [contenteditable], [role='textbox'], [role='searchbox'], [role='button']\";\n\n/** The item's box relative to the container, summing offsets through positioned ancestors in between. */\nexport function rectOf(item: HTMLElement, container: HTMLElement): FluidRect {\n  let top = item.offsetTop, left = item.offsetLeft;\n  let parent = item.offsetParent as HTMLElement | null;\n  while (parent && parent !== container && container.contains(parent)) {\n    top += parent.offsetTop + parent.clientTop;\n    left += parent.offsetLeft + parent.clientLeft;\n    parent = parent.offsetParent as HTMLElement | null;\n  }\n  return { top, left, width: item.offsetWidth, height: item.offsetHeight };\n}\n\n/**\n * The item the pointer is inside, or else the one whose edge is nearest along the axis (both axes for a grid). Edges, not\n * centres, so a gap next to a wide item still lights the item the pointer is closer to.\n */\nexport function pickNearest(items: HTMLElement[], x: number, y: number, axis: \"x\" | \"y\" | \"xy\"): HTMLElement | null {\n  let nearest: HTMLElement | null = null, best = Infinity;\n  for (const item of items) {\n    const r = item.getBoundingClientRect();\n    if (!r.width && !r.height) continue;\n    if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return item;\n    const dx = Math.max(r.left - x, 0, x - r.right), dy = Math.max(r.top - y, 0, y - r.bottom);\n    const distance = axis === \"x\" ? dx : axis === \"y\" ? dy : Math.hypot(dx, dy);\n    if (distance < best) { best = distance; nearest = item; }\n  }\n  return nearest;\n}\n\n/** Hands a gap click to the item as a real mouse click (detail, modifiers and coordinates kept), so the item reads it as a pointer press, not a keyboard one. */\nfunction forwardClick(item: HTMLElement, event: MouseEvent<HTMLElement>) {\n  const { detail, clientX, clientY, screenX, screenY, ctrlKey, shiftKey, altKey, metaKey, button, buttons } = event;\n  item.dispatchEvent(new globalThis.MouseEvent(\"click\", { bubbles: true, cancelable: true, composed: true, view: window, detail, clientX, clientY, screenX, screenY, ctrlKey, shiftKey, altKey, metaKey, button, buttons }));\n}\n\n/** Whether an event happened inside the element whose handler hears it, not in a portalled child (a submenu, a tooltip). */\nconst isOwn = (event: { currentTarget: EventTarget; target: EventTarget }) =>\n  !(event.currentTarget instanceof Node) || !(event.target instanceof Node) || event.currentTarget.contains(event.target);\n\nexport function useFluidHover<T extends HTMLElement>(container: RefObject<T | null>, options: FluidHoverOptions) {\n  const { items: selector, axis = \"y\", isItemDisabled = defaultDisabled, disabled = \"skip\", gapClick = true, followFocus = true } = options;\n  const [state, setState] = useState<FluidHoverState>({ rect: null, item: null, glide: false, session: 0 });\n  const current = useRef(state);\n  const pointer = useRef(false);\n  const entering = useRef(false);\n  const frame = useRef(0);\n\n  function commit(next: FluidHoverState) {\n    const prev = current.current;\n    if (prev.item === next.item && prev.session === next.session && sameRect(prev.rect, next.rect)) return;\n    current.current = next;\n    setState(next);\n  }\n\n  function light(item: HTMLElement | null, glide: boolean) {\n    const root = container.current;\n    if (!root || !item) return commit({ ...current.current, rect: null, item: null, glide });\n    let session = current.current.session;\n    if (entering.current) { entering.current = false; session += 1; }\n    commit({ rect: rectOf(item, root), item, glide: glide && session === current.current.session, session });\n  }\n\n  const enabledItems = () => {\n    const root = container.current;\n    return root ? [...root.querySelectorAll<HTMLElement>(selector)].filter(item => !isItemDisabled(item)) : [];\n  };\n\n  // The lit item can move under a still highlight (a resize, content above it changing): follow it without travel.\n  const follow = useEffectEvent(() => { const { item } = current.current; if (item?.isConnected) light(item, false); });\n  useEffect(() => {\n    const root = container.current;\n    if (!root || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => follow());\n    observer.observe(root);\n    return () => observer.disconnect();\n  }, [container]);\n  useEffect(() => () => cancelAnimationFrame(frame.current), []);\n\n  const handlers = {\n    onMouseEnter: () => { entering.current = true; },\n    onMouseMove: (event: MouseEvent<T>) => {\n      if (!isOwn(event)) return;\n      pointer.current = true;\n      const { clientX, clientY } = event;\n      cancelAnimationFrame(frame.current);\n      frame.current = requestAnimationFrame(() => {\n        const over = disabled === \"clear\" ? document.elementFromPoint(clientX, clientY)?.closest<HTMLElement>(selector) : null;\n        light(over && isItemDisabled(over) ? null : pickNearest(enabledItems(), clientX, clientY, axis), true);\n      });\n    },\n    onMouseLeave: () => { cancelAnimationFrame(frame.current); entering.current = false; light(null, false); },\n    onKeyDownCapture: () => { pointer.current = false; },\n    onFocus: (event: FocusEvent<T>) => {\n      if (!followFocus || pointer.current || !isOwn(event)) return;\n      const item = event.target instanceof HTMLElement ? event.target.closest<HTMLElement>(selector) : null;\n      if (item && container.current?.contains(item) && !isItemDisabled(item)) light(item, false);\n    },\n    onBlur: (event: FocusEvent<T>) => {\n      if (!pointer.current && !event.currentTarget.contains(event.relatedTarget as Node | null)) light(null, false);\n    },\n    onClick: (event: MouseEvent<T>) => {\n      if (!gapClick || !isOwn(event)) return;\n      const target = event.target as Element;\n      if (!target.isConnected || target.closest(selector) || target.closest(CONTROL)) return;\n      const { item } = current.current;\n      if (item?.isConnected && !isItemDisabled(item)) forwardClick(item, event);\n    },\n  };\n\n  /** Clears the highlight at once, such as when a menu opens again. */\n  const reset = () => { cancelAnimationFrame(frame.current); entering.current = true; light(null, false); };\n\n  return { ...state, handlers, reset, light };\n}\n\nexport interface NearestItemOptions extends Omit<FluidHoverOptions, \"followFocus\"> {\n  /** Called with the nearest enabled item as the mouse moves over the container. */\n  onPick: (item: HTMLElement) => void;\n}\n\n/**\n * Fluid hover's picking without its highlight, for a list that already keeps its own active item and highlight (a listbox\n * with aria-activedescendant, a roving list): the mouse picks the nearest enabled item, gaps and padding included, and a\n * click in a gap goes to that item. Spread the result on the container.\n */\n// A hook only so callbacks that touch refs can be passed during render; it keeps no state.\nexport function useNearestItem<T extends HTMLElement>({ items: selector, axis = \"y\", isItemDisabled = defaultDisabled, gapClick = true, onPick }: NearestItemOptions) {\n  const nearest = (event: MouseEvent<T>) => {\n    if (!isOwn(event)) return null;\n    const items = [...event.currentTarget.querySelectorAll<HTMLElement>(selector)].filter(item => !isItemDisabled(item));\n    return pickNearest(items, event.clientX, event.clientY, axis);\n  };\n  return {\n    onPointerMove: (event: PointerEvent<T>) => { if (event.pointerType !== \"mouse\") return; const item = nearest(event); if (item) onPick(item); },\n    onClick: (event: MouseEvent<T>) => {\n      if (!gapClick) return;\n      const target = event.target as Element;\n      if (!target.isConnected || target.closest(selector) || target.closest(CONTROL)) return;\n      const item = nearest(event);\n      if (item) forwardClick(item, event);\n    },\n  };\n}\n\nconst sameRect = (a: FluidRect | null, b: FluidRect | null) =>\n  a === b || (!!a && !!b && a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height);\n\nexport interface FluidHoverHighlightProps {\n  /** The state from useFluidHover (or any { rect, glide, session }). */\n  hover: Pick<FluidHoverState, \"rect\" | \"glide\" | \"session\">;\n  /** Radius, fill, z-index. The container must be position: relative; the highlight is placed at its top-left corner. */\n  className?: string;\n  /** Extra attributes for styling, such as data-tone. */\n  tone?: string;\n}\n\n/** The one highlight a fluid hover set renders: it springs between items on transforms, and fades in and out. */\nexport function FluidHoverHighlight({ hover, className, tone }: FluidHoverHighlightProps) {\n  const reduced = useReducedMotion() ?? false;\n  const { rect, glide, session } = hover;\n  const place = rect ? { x: rect.left, y: rect.top, width: rect.width, height: rect.height } : undefined;\n  return (\n    <AnimatePresence initial={false}>\n      {place ? (\n        <motion.span key={session} className={className} data-tone={tone} aria-hidden=\"true\"\n          style={{ position: \"absolute\", top: 0, left: 0, pointerEvents: \"none\" }}\n          initial={{ opacity: 0, ...place }}\n          animate={{ opacity: 1, ...place }}\n          exit={{ opacity: 0, transition: motionTokens.spring.fast.exit }}\n          transition={{ default: glide && !reduced ? motionTokens.spring.fast : { duration: 0 }, opacity: { duration: motionTokens.duration.fast } }} />\n      ) : null}\n    </AnimatePresence>\n  );\n}\n"}],"docs":"Docs and live preview: https://ui.teamofsilicons.com/user-menu","meta":{"tier":"free","kind":"component","docs":"https://ui.teamofsilicons.com/user-menu","markdown":"https://ui.teamofsilicons.com/user-menu.md","tags":["motion","product"],"status":"stable"}}