{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "message-status",
  "type": "registry:ui",
  "title": "Message Status",
  "description": "Delivery ticks that morph from clock to check to read, with a pressable retry.",
  "categories": [
    "messaging"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/message-status.tsx",
      "content": "\"use client\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport { AnimatePresence, motion, useAnimate, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type MessageStatusValue = \"sending\" | \"sent\" | \"delivered\" | \"read\" | \"failed\";\n\nexport const statusLabels: Record<MessageStatusValue, string> = {\n  sending: \"Sending…\",\n  sent: \"Sent\",\n  delivered: \"Delivered\",\n  read: \"Read\",\n  failed: \"Not delivered\",\n};\n\n// A send that settles inside this window never shows the clock at all: a clock\n// that flashes for 80ms reads as a glitch, not as progress.\nconst SENDING_DELAY = 0.3;\n\nexport type MessageStatusProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  status: MessageStatusValue;\n  /** When it happened, already formatted (\"9:41\"). Joins the label and the accessible name: \"Read 9:41\". */\n  time?: string;\n  /** Show the words next to the ticks. Off, the ticks carry an accessible name instead. */\n  label?: boolean;\n  /** Which side the ticks sit on. \"end\" keeps them on the right edge, under a right-aligned bubble. */\n  align?: \"start\" | \"end\";\n  /** Resend a failed message. When set, the failed state becomes a button. */\n  onRetry?: () => void;\n  /** Words for each state, for other languages or products. */\n  labels?: Partial<Record<MessageStatusValue, string>>;\n  /** Text of the retry action. */\n  retryLabel?: string;\n  /** Say state changes out loud. Failures are always announced; the rest only when this is on. */\n  announce?: boolean;\n};\n\n/**\n * Ticks that morph: a clock that only shows up when sending is slow, a check that draws,\n * a second check that slides in beside it, a brightening for read, and a failure you can press.\n */\nexport function MessageStatus({\n  status,\n  time,\n  label = false,\n  align = \"end\",\n  onRetry,\n  labels: labelsProp,\n  retryLabel = \"Try again\",\n  announce = false,\n  className,\n  ...rest\n}: MessageStatusProps) {\n  const reduce = useReducedMotion();\n  const labels = { ...statusLabels, ...labelsProp };\n  const withTime = (s: MessageStatusValue) => (time && s !== \"sending\" && s !== \"failed\" ? `${labels[s]} ${time}` : labels[s]);\n  const text = withTime(status);\n  const failed = status === \"failed\";\n  const retry = failed && !!onRetry;\n  const spoken = failed && onRetry ? `${labels.failed}. ${retryLabel}` : text;\n\n  const glyph = <Ticks status={status} reduce={!!reduce} />;\n\n  // Pressing retry swaps the button for a plain status, which would drop focus on the page.\n  // Hand it to the status instead, so keyboard and screen reader users stay on the message.\n  const root = useRef<HTMLSpanElement>(null);\n  const refocus = useRef(false);\n  useEffect(() => {\n    if (!refocus.current || failed) return;\n    refocus.current = false;\n    root.current?.focus({ preventScroll: true });\n  }, [failed]);\n\n  const words = label && (\n    // Every label sits in the same grid cell, so the row is as wide as the longest one and\n    // the ticks never move. The text hugs the ticks; the spare room falls on the far side.\n    <span className={cn(\"grid overflow-hidden\", align === \"end\" ? \"justify-items-end text-right\" : \"justify-items-start text-left\")} aria-hidden>\n      {(Object.keys(labels) as MessageStatusValue[]).map((s) => (\n        <span key={s} className=\"invisible col-start-1 row-start-1 whitespace-nowrap\">\n          {s === \"failed\" && onRetry ? <FailedWords text={labels.failed} retry={retryLabel} /> : withTime(s)}\n        </span>\n      ))}\n      <AnimatePresence initial={false}>\n        <motion.span\n          key={status}\n          className=\"col-start-1 row-start-1 whitespace-nowrap\"\n          initial={reduce ? { opacity: 0 } : { opacity: 0, y: 5, filter: \"blur(2px)\" }}\n          animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n          exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, y: -5, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } }}\n          transition={{ duration: reduce ? 0.15 : 0.22, ease: ease.out, delay: status === \"sending\" ? SENDING_DELAY : 0 }}\n        >\n          {failed && onRetry ? <FailedWords text={labels.failed} retry={retryLabel} /> : text}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n\n  const inner = (\n    <>\n      {align === \"start\" && glyph}\n      {words}\n      {align === \"end\" && glyph}\n    </>\n  );\n\n  const shared = cn(\n    \"relative inline-flex shrink-0 select-none items-center gap-1 align-middle text-[11px] leading-4 tabular\",\n    \"transition-colors duration-200 ease-out\",\n    \"text-fg-3 data-[status=read]:text-fg data-[status=failed]:text-danger\",\n    className,\n  );\n\n  return (\n    <>\n      {retry ? (\n        <RetryButton\n          label={label}\n          name={spoken}\n          className={shared}\n          status={status}\n          onRetry={() => {\n            refocus.current = true;\n            onRetry?.();\n          }}\n          {...rest}\n        >\n          {inner}\n        </RetryButton>\n      ) : (\n        <span ref={root} role=\"img\" aria-label={spoken} tabIndex={-1} data-status={status} className={cn(shared, \"rounded-sm outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\")} {...rest}>\n          {inner}\n        </span>\n      )}\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {failed ? `Message not delivered.${onRetry ? ` ${retryLabel} is available.` : \"\"}` : announce ? text : \"\"}\n      </span>\n    </>\n  );\n}\n\nfunction FailedWords({ text, retry }: { text: string; retry: string }) {\n  return (\n    <>\n      {text}\n      <span className=\"px-1 opacity-60\">·</span>\n      <span className=\"font-medium underline decoration-current/40 underline-offset-2 transition-[text-decoration-color] duration-150 group-hover/retry:decoration-current\">\n        {retry}\n      </span>\n    </>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * The failed state as a button: whole label when it shows, the glyph alone (with a tooltip) when not\n * -----------------------------------------------------------------------------------------------*/\n\nfunction RetryButton({\n  label,\n  name,\n  status,\n  onRetry,\n  className,\n  children,\n  ...rest\n}: Omit<React.ComponentProps<\"span\">, \"onClick\"> & { label: boolean; name: string; status: MessageStatusValue; onRetry?: () => void }) {\n  const button = (\n    <button\n      type=\"button\"\n      aria-label={name}\n      data-status={status}\n      onClick={onRetry}\n      className={cn(\n        className,\n        \"group/retry rounded-sm outline-none active:scale-[0.95] active:duration-75\",\n        \"transition-[color,scale] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        // A 16px glyph is too small to hit with a thumb; the target grows without the drawing.\n        \"before:absolute before:-inset-3 before:content-[''] pointer-fine:before:-inset-1\",\n      )}\n      {...(rest as React.ComponentProps<\"button\">)}\n    >\n      {children}\n    </button>\n  );\n  if (label) return button;\n  return (\n    <Tooltip.Root>\n      <Tooltip.Trigger delay={300} render={button} />\n      <Tooltip.Portal>\n        <Tooltip.Positioner side=\"top\" sideOffset={8} collisionPadding={8} className=\"z-(--z-tooltip)\">\n          <Tooltip.Popup\n            className={cn(\n              \"rounded-md border border-line-2 bg-raised px-2 py-1 text-[12px] leading-4 text-fg shadow-pop outline-none\",\n              \"origin-(--transform-origin) transition-[opacity,scale] duration-150 ease-out-expo\",\n              \"data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-100\",\n              \"data-instant:transition-none motion-reduce:data-starting-style:scale-100\",\n            )}\n          >\n            {name}\n          </Tooltip.Popup>\n        </Tooltip.Positioner>\n      </Tooltip.Portal>\n    </Tooltip.Root>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Ticks: one drawing that morphs between the states, never a stack of swapped icons\n * -----------------------------------------------------------------------------------------------*/\n\n// Offsets that keep the drawing optically centered in its 18px box: one check sits in the\n// middle; two checks share the middle between them.\nconst A_ALONE = 2.75;\nconst A_PAIR = 0.5;\n\nfunction Ticks({ status, reduce }: { status: MessageStatusValue; reduce: boolean }) {\n  const [scope, animate] = useAnimate<SVGGElement>();\n  const previous = useRef(status);\n\n  // Read is a change that happens while nobody is looking: the pair gives one small nod\n  // as it brightens, so the change is caught from the corner of the eye.\n  useEffect(() => {\n    const was = previous.current;\n    previous.current = status;\n    if (reduce || !scope.current || was === status || status !== \"read\") return;\n    animate(scope.current, { scale: [1, 1.18, 1] }, { duration: 0.34, ease: ease.out, times: [0, 0.4, 1] });\n  }, [status, reduce, animate, scope]);\n\n  const sending = status === \"sending\";\n  const failed = status === \"failed\";\n  const checked = status === \"sent\" || status === \"delivered\" || status === \"read\";\n  const pair = status === \"delivered\" || status === \"read\";\n\n  // Targets never depend on reduced motion (the server can't know it); only the timing does.\n  const draw = (on: boolean, delay = 0) => ({\n    animate: { opacity: on ? 1 : 0, pathLength: on ? 1 : 0 },\n    transition: reduce\n      ? { duration: on ? 0.15 : 0.1, pathLength: { duration: 0 } }\n      : on\n        ? { pathLength: { duration: 0.3, ease: ease.out, delay }, opacity: { duration: 0.05, delay } }\n        : { pathLength: { duration: 0.12, ease: ease.in }, opacity: { duration: 0.12 } },\n  });\n\n  const a = draw(checked);\n  const b = draw(pair, status === \"delivered\" || status === \"read\" ? 0.06 : 0);\n\n  return (\n    <span className=\"relative grid h-4 w-[18px] shrink-0 place-items-center\" aria-hidden>\n      <svg width=\"18\" height=\"16\" viewBox=\"0 0 18 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"overflow-visible\">\n        {/* Clock: fades in only if sending takes longer than the delay; the hand turns while it waits. */}\n        <motion.g\n          initial={false}\n          animate={sending ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.6 }}\n          transition={reduce ? { duration: 0.15, delay: sending ? SENDING_DELAY : 0, scale: { duration: 0 } } : sending ? { duration: 0.2, ease: ease.out, delay: SENDING_DELAY } : { duration: 0.12, ease: ease.in }}\n        >\n          <circle cx=\"9\" cy=\"8\" r=\"5.25\" />\n          <path d=\"M9 8 11.1 9.2\" />\n          <path d=\"M9 8V4.9\" className={cn(\"origin-[9px_8px]\", sending && \"motion-safe:animate-spin motion-safe:[animation-duration:2.4s]\")} />\n        </motion.g>\n\n        <motion.g ref={scope}>\n          <motion.g initial={false} animate={{ x: pair ? A_PAIR : A_ALONE }} transition={reduce ? { duration: 0 } : spring.snappy}>\n            <motion.path d=\"M2 8.5 5 11.5 10.5 5\" initial={false} {...a} />\n          </motion.g>\n          <g transform={`translate(${A_PAIR} 0)`}>\n            <motion.path d=\"M8.3 10.3 9.5 11.5 15 5\" initial={false} {...b} />\n          </g>\n        </motion.g>\n      </svg>\n\n      <AnimatePresence initial={false}>\n        {failed && (\n          <motion.svg\n            key=\"failed\"\n            width=\"16\"\n            height=\"16\"\n            viewBox=\"0 0 16 16\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={1.4}\n            strokeLinecap=\"round\"\n            className=\"absolute\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(2px)\" }}\n            animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, transition: { duration: 0.12 } }}\n            transition={reduce ? { duration: 0.15 } : spring.pop}\n          >\n            <circle cx=\"8\" cy=\"8\" r=\"5.75\" />\n            <path d=\"M8 5v3.5\" />\n            <circle cx=\"8\" cy=\"10.9\" r=\".6\" fill=\"currentColor\" stroke=\"none\" />\n          </motion.svg>\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/message-status"
}
