{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "session-timeout",
  "type": "registry:ui",
  "title": "Session Timeout",
  "description": "An idle sign-out warning whose countdown rolls down and drains to zero.",
  "categories": [
    "onboarding"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/session-timeout.tsx",
      "content": "\"use client\";\nimport { AlertDialog } from \"@base-ui/react/alert-dialog\";\nimport NumberFlow, { NumberFlowGroup } from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Clock, Lock } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nconst ACTIVITY = [\n  \"pointerdown\",\n  \"keydown\",\n  \"wheel\",\n  \"touchstart\",\n  \"scroll\",\n] as const;\n\nexport type UseIdleWarningOptions = {\n  /** Milliseconds without activity before the warning opens. */\n  idleAfter: number;\n  /** Stop listening, e.g. while signed out. */\n  disabled?: boolean;\n};\n\n/**\n * Watches for input and says when the person has been idle long enough to warn.\n * Once warning, activity no longer counts: staying signed in is a choice.\n */\nexport function useIdleWarning({\n  idleAfter,\n  disabled = false,\n}: UseIdleWarningOptions) {\n  const [warning, setWarning] = useState(false);\n  const last = useRef(0);\n  const warningRef = useRef(false);\n\n  const reset = useCallback(() => {\n    last.current = Date.now();\n    warningRef.current = false;\n    setWarning(false);\n  }, []);\n\n  useEffect(() => {\n    if (disabled) return;\n    last.current = Date.now();\n    const bump = () => {\n      if (!warningRef.current) last.current = Date.now();\n    };\n    // Checked on a slow interval and when the tab comes back, measured against the\n    // clock, so a throttled background tab still warns on time.\n    const check = () => {\n      if (warningRef.current || Date.now() - last.current < idleAfter) return;\n      warningRef.current = true;\n      setWarning(true);\n    };\n    ACTIVITY.forEach((e) =>\n      window.addEventListener(e, bump, { passive: true, capture: true }),\n    );\n    document.addEventListener(\"visibilitychange\", check);\n    const timer = window.setInterval(check, 1000);\n    return () => {\n      ACTIVITY.forEach((e) =>\n        window.removeEventListener(e, bump, { capture: true }),\n      );\n      document.removeEventListener(\"visibilitychange\", check);\n      window.clearInterval(timer);\n    };\n  }, [idleAfter, disabled]);\n\n  return { warning, reset };\n}\n\nexport type SessionTimeoutProps = {\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** When the session ends. Takes precedence over `duration`. */\n  expiresAt?: number | Date;\n  /** Seconds of warning, counted from when the dialog opens. */\n  duration?: number;\n  /** Refresh the session. Return a promise to show progress; a rejection keeps the dialog open with the reason. */\n  onStaySignedIn?: () => void | Promise<unknown>;\n  onSignOut?: () => void | Promise<unknown>;\n  /** Called once, when the countdown reaches zero. Sign the person out here. */\n  onExpire?: () => void;\n  /** The button shown after expiry. Without it, the button just closes the dialog. */\n  onSignIn?: () => void;\n  title?: React.ReactNode;\n  description?: React.ReactNode;\n  /** Render inside this element instead of document.body. */\n  container?: HTMLElement | null;\n  className?: string;\n};\n\n/**\n * The “are you still there” warning. The countdown is measured against the\n * clock, rolls down a digit at a time, and the dialog turns into a signed-out\n * notice in place when it runs out.\n */\nexport function SessionTimeout({\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  container,\n  className,\n  ...rest\n}: SessionTimeoutProps) {\n  const [open, setOpen] = useControllableState({\n    value: openProp,\n    defaultValue: defaultOpen,\n    onChange: onOpenChange,\n  });\n  const stayRef = useRef<(() => void) | null>(null);\n  const contained = container != null;\n\n  return (\n    <AlertDialog.Root\n      open={open}\n      onOpenChange={(next, details) => {\n        // Escape is a sign of life: treat it as Stay signed in rather than a silent close.\n        if (!next && details.reason === \"escape-key\" && stayRef.current) {\n          details.cancel();\n          stayRef.current();\n          return;\n        }\n        setOpen(next);\n      }}\n    >\n      <AlertDialog.Portal container={container ?? undefined}>\n        <AlertDialog.Backdrop\n          className={cn(\n            contained ? \"absolute\" : \"fixed\",\n            \"inset-0 z-(--z-overlay) bg-overlay transition-opacity duration-200 ease-out-quart\",\n            \"data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-150\",\n          )}\n        />\n        <AlertDialog.Viewport\n          className={cn(\n            contained ? \"absolute\" : \"fixed\",\n            \"inset-0 z-(--z-dialog) flex items-center justify-center p-4\",\n            \"max-sm:items-end max-sm:p-2 max-sm:pb-[max(8px,env(safe-area-inset-bottom))]\",\n          )}\n        >\n          <Body\n            {...rest}\n            className={className}\n            close={() => setOpen(false)}\n            stayRef={stayRef}\n          />\n        </AlertDialog.Viewport>\n      </AlertDialog.Portal>\n    </AlertDialog.Root>\n  );\n}\n\ntype Phase = \"counting\" | \"staying\" | \"leaving\" | \"expired\";\n\n// Lives inside the popup, so each opening starts with fresh state.\nfunction Body({\n  expiresAt,\n  duration = 60,\n  onStaySignedIn,\n  onSignOut,\n  onExpire,\n  onSignIn,\n  title = \"You’re about to be signed out\",\n  description = \"For your security, we sign you out after a while without activity. Anything unsaved on this page stays here.\",\n  className,\n  close,\n  stayRef,\n}: Omit<\n  SessionTimeoutProps,\n  \"open\" | \"defaultOpen\" | \"onOpenChange\" | \"container\"\n> & {\n  close: () => void;\n  stayRef: React.RefObject<(() => void) | null>;\n}) {\n  const [clock, setClock] = useState<{ left: number; total: number } | null>(\n    null,\n  );\n  const [phase, setPhase] = useState<Phase>(\"counting\");\n  const [error, setError] = useState<string | null>(null);\n  const stayButton = useRef<HTMLButtonElement>(null);\n  const signInButton = useRef<HTMLButtonElement>(null);\n  const expiredOnce = useRef(false);\n  const live = useRef({ onExpire });\n  const reduce = useReducedMotion();\n\n  useEffect(() => {\n    live.current = { onExpire };\n  });\n\n  useEffect(() => {\n    const start = Date.now();\n    const end = expiresAt != null ? +expiresAt : start + duration * 1000;\n    const total = Math.max(1, Math.round((end - start) / 1000));\n    const tick = () => {\n      const left = Math.max(0, Math.ceil((end - Date.now()) / 1000));\n      setClock((prev) => (prev?.left === left ? prev : { left, total }));\n      if (left === 0 && !expiredOnce.current) {\n        expiredOnce.current = true;\n        setPhase(\"expired\");\n        live.current.onExpire?.();\n      }\n    };\n    const first = requestAnimationFrame(tick);\n    // A quarter-second tick keeps the display within a frame of the true second, even after the tab was hidden.\n    const timer = window.setInterval(tick, 250);\n    document.addEventListener(\"visibilitychange\", tick);\n    return () => {\n      cancelAnimationFrame(first);\n      window.clearInterval(timer);\n      document.removeEventListener(\"visibilitychange\", tick);\n    };\n  }, [expiresAt, duration]);\n\n  // Hand focus to the new primary action when the dialog turns into the signed-out notice.\n  useEffect(() => {\n    if (phase === \"expired\") signInButton.current?.focus();\n  }, [phase]);\n\n  const run = async (\n    fn: (() => void | Promise<unknown>) | undefined,\n    busy: Phase,\n    failure: string,\n  ) => {\n    if (phase !== \"counting\") return;\n    setError(null);\n    const result = fn?.();\n    if (!(result instanceof Promise)) return close();\n    setPhase(busy);\n    try {\n      await result;\n      close();\n    } catch {\n      // If the clock ran out while we waited, the expired notice has already taken over.\n      setPhase((p) => (p === \"expired\" ? p : \"counting\"));\n      setError(failure);\n    }\n  };\n  const stay = () =>\n    run(\n      onStaySignedIn,\n      \"staying\",\n      \"Couldn’t refresh your session. Try again, or save your work somewhere safe.\",\n    );\n\n  useEffect(() => {\n    // Escape means stay, and does nothing while a request is in flight (run() ignores it).\n    stayRef.current = phase === \"expired\" ? null : stay;\n  });\n\n  const expired = phase === \"expired\";\n  const left = clock?.left ?? 0;\n  const urgent = !expired && clock != null && left <= 10;\n  const minutes = Math.floor(left / 60);\n  const seconds = left % 60;\n  const spoken = `${minutes ? `${minutes} minute${minutes > 1 ? \"s\" : \"\"} ` : \"\"}${seconds} second${seconds === 1 ? \"\" : \"s\"}`;\n  // Announce a few moments, not every second.\n  const announce = expired\n    ? \"You’ve been signed out.\"\n    : clock && [60, 30, 10].includes(left)\n      ? `${spoken} left before you’re signed out.`\n      : \"\";\n\n  const swap = {\n    initial: reduce\n      ? { opacity: 0 }\n      : { opacity: 0, y: 6, filter: \"blur(2px)\" },\n    animate: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n    exit: reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\" },\n    transition: { duration: reduce ? 0.12 : 0.24, ease: ease.out },\n  };\n\n  return (\n    <AlertDialog.Popup\n      initialFocus={stayButton}\n      data-phase={phase}\n      onKeyDown={(e) => {\n        // Keep Tab inside the dialog, wherever it has been portaled.\n        if (e.key !== \"Tab\") return;\n        const items = [...e.currentTarget.querySelectorAll<HTMLElement>(\"button, [href]\")].filter((el) => !el.closest(\"[inert]\"));\n        const at = items.indexOf(document.activeElement as HTMLElement);\n        const to = e.shiftKey ? (at <= 0 ? items.length - 1 : at - 1) : at === items.length - 1 ? 0 : at + 1;\n        if (items[to]) {\n          e.preventDefault();\n          items[to].focus();\n        }\n      }}\n      className={cn(\n        \"relative flex w-full max-w-[380px] flex-col overflow-hidden rounded-2xl border border-line-2 bg-raised text-fg shadow-pop outline-none\",\n        \"origin-center transition-[opacity,scale,translate] duration-[260ms] ease-out-expo\",\n        \"data-starting-style:translate-y-1.5 data-starting-style:scale-[0.97] data-starting-style:opacity-0\",\n        \"data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-[160ms] data-ending-style:ease-out-quart\",\n        \"max-sm:data-starting-style:translate-y-4 max-sm:data-starting-style:scale-100\",\n        \"motion-reduce:data-starting-style:translate-y-0 motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100\",\n        className,\n      )}\n    >\n      {/* The time left, draining along the top edge; it turns amber for the last ten seconds. */}\n      <div aria-hidden className=\"absolute inset-x-0 top-0 h-0.5 bg-line\">\n        <div\n          className={cn(\n            \"h-full origin-left transition-[transform,background-color] duration-1000 ease-linear motion-reduce:transition-none\",\n            urgent ? \"bg-warning\" : \"bg-fg-3\",\n            expired && \"opacity-0\",\n          )}\n          style={{ transform: `scaleX(${clock ? left / clock.total : 1})` }}\n        />\n      </div>\n\n      <div className=\"p-5\">\n        <div className=\"grid\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.div\n              key={expired ? \"expired\" : \"counting\"}\n              className=\"col-start-1 row-start-1\"\n              {...swap}\n            >\n              <span\n                aria-hidden\n                className=\"mb-4 grid size-9 place-items-center rounded-full bg-hover text-fg-2\"\n              >\n                {expired ? <Lock /> : <Clock />}\n              </span>\n              <AlertDialog.Title className=\"text-[15px] font-medium leading-[1.3] tracking-[-0.015em] text-fg text-balance\">\n                {expired ? \"You’ve been signed out\" : title}\n              </AlertDialog.Title>\n              <AlertDialog.Description className=\"mt-1.5 text-[13px] leading-[1.5] text-fg-2 text-pretty\">\n                {expired\n                  ? \"Your work on this page is still here. Sign in again to pick up where you left off.\"\n                  : description}\n              </AlertDialog.Description>\n            </motion.div>\n          </AnimatePresence>\n        </div>\n\n        {/* On expiry the countdown folds away rather than vanishing, so the dialog settles instead of jumping. */}\n        <div\n          data-open={expired ? undefined : \"\"}\n          inert={expired}\n          className=\"grid grid-rows-[0fr] opacity-0 transition-[grid-template-rows,opacity] duration-300 ease-in-out-quart data-open:grid-rows-[1fr] data-open:opacity-100\"\n        >\n          <div className=\"min-h-0 overflow-hidden\">\n            <div className=\"mt-4 flex items-baseline justify-between gap-3 rounded-xl border border-line bg-frame px-3.5 py-3\">\n              <span className=\"text-[12.5px] text-fg-2\">\n                You will be signed out in\n              </span>\n              <span\n                aria-hidden\n                className={cn(\n                  \"font-mono text-[22px] font-medium leading-none tracking-[-0.02em] transition-[color,opacity] duration-300\",\n                  urgent ? \"text-warning\" : \"text-fg\",\n                  clock ? \"opacity-100\" : \"opacity-0\",\n                )}\n              >\n                {clock && (\n                  <NumberFlowGroup>\n                    <NumberFlow\n                      value={minutes}\n                      trend={-1}\n                      className=\"tabular\"\n                    />\n                    <span className=\"-mx-[0.08em]\">:</span>\n                    <NumberFlow\n                      value={seconds}\n                      trend={-1}\n                      format={{ minimumIntegerDigits: 2 }}\n                      className=\"tabular\"\n                    />\n                  </NumberFlowGroup>\n                )}\n              </span>\n              <span className=\"sr-only\">{spoken}</span>\n            </div>\n          </div>\n        </div>\n\n        <div\n          data-open={error ? \"\" : undefined}\n          className=\"grid grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-in-out-quart data-open:grid-rows-[1fr]\"\n        >\n          <div className=\"min-h-0 overflow-hidden\">\n            <p\n              role=\"alert\"\n              className=\"mt-3 rounded-lg bg-danger-soft px-3 py-2 text-[12.5px] leading-[1.45] text-danger\"\n            >\n              {error}\n            </p>\n          </div>\n        </div>\n\n        <div className=\"mt-5 flex items-center justify-end gap-2 max-sm:grid max-sm:auto-cols-fr max-sm:grid-flow-col max-sm:*:h-10\">\n          {expired ? (\n            <button\n              ref={signInButton}\n              type=\"button\"\n              onClick={() => {\n                onSignIn?.();\n                close();\n              }}\n              className={cn(button, primary)}\n            >\n              Sign in again\n            </button>\n          ) : (\n            <>\n              <button\n                type=\"button\"\n                aria-busy={phase === \"leaving\" || undefined}\n                aria-disabled={phase === \"staying\" || undefined}\n                onClick={() =>\n                  run(onSignOut, \"leaving\", \"Couldn’t sign you out. Try again.\")\n                }\n                className={cn(\n                  button,\n                  \"border border-line-2 bg-raised text-fg hover:border-fg-4 hover:bg-hover\",\n                )}\n              >\n                <Label\n                  busy={phase === \"leaving\"}\n                  idle=\"Sign out\"\n                  pending=\"Signing out…\"\n                />\n              </button>\n              <button\n                ref={stayButton}\n                type=\"button\"\n                aria-busy={phase === \"staying\" || undefined}\n                aria-disabled={phase === \"leaving\" || undefined}\n                onClick={stay}\n                className={cn(button, primary)}\n              >\n                <Label\n                  busy={phase === \"staying\"}\n                  idle=\"Stay signed in\"\n                  pending=\"Refreshing…\"\n                />\n              </button>\n            </>\n          )}\n        </div>\n      </div>\n      <p role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </p>\n    </AlertDialog.Popup>\n  );\n}\n\nconst button = cn(\n  \"relative inline-flex h-8 shrink-0 select-none items-center justify-center whitespace-nowrap rounded-lg px-3 text-[12.5px] font-medium shadow-[var(--shadow)] outline-none\",\n  \"transition-[background-color,border-color,color,opacity,scale] duration-150 ease-out-quart active:scale-[0.97] active:duration-75\",\n  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n  \"aria-disabled:pointer-events-none aria-disabled:opacity-45 aria-busy:pointer-events-none\",\n);\nconst primary = \"bg-fg text-frame hover:bg-fg/90\";\n\n// Both labels share one cell, so a busy button keeps its width.\nfunction Label({\n  busy,\n  idle,\n  pending,\n}: {\n  busy: boolean;\n  idle: string;\n  pending: string;\n}) {\n  const reduce = useReducedMotion();\n  return (\n    <span className=\"grid overflow-hidden py-1\">\n      <span aria-hidden className=\"invisible col-start-1 row-start-1\">\n        {idle}\n      </span>\n      <span aria-hidden className=\"invisible col-start-1 row-start-1 pl-[22px]\">\n        {pending}\n      </span>\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          key={busy ? \"busy\" : \"idle\"}\n          className=\"col-start-1 row-start-1 flex items-center justify-center gap-1.5\"\n          initial={\n            reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: \"blur(2px)\" }\n          }\n          animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n          exit={\n            reduce ? { opacity: 0 } : { opacity: 0, y: -8, filter: \"blur(2px)\" }\n          }\n          transition={{ duration: reduce ? 0.12 : 0.2, ease: ease.out }}\n        >\n          {busy && (\n            <svg\n              width=\"16\"\n              height=\"16\"\n              viewBox=\"0 0 16 16\"\n              fill=\"none\"\n              aria-hidden\n              className=\"shrink-0 animate-[spin_0.7s_linear_infinite]\"\n            >\n              <circle\n                cx=\"8\"\n                cy=\"8\"\n                r=\"5.75\"\n                stroke=\"currentColor\"\n                strokeOpacity=\"0.25\"\n                strokeWidth=\"1.5\"\n              />\n              <path\n                d=\"M8 2.25A5.75 5.75 0 0 1 13.75 8\"\n                stroke=\"currentColor\"\n                strokeWidth=\"1.5\"\n                strokeLinecap=\"round\"\n              />\n            </svg>\n          )}\n          {busy ? pending : idle}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/session-timeout"
}
