{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "jump-bar",
  "title": "Jump bar",
  "author": "Tori Bryan",
  "description": "Takes people to unread messages, new messages or the present in a conversation.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://fibo.toribryan.com/r/button.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/jump-bar.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowDownIcon, ArrowUpIcon } from \"lucide-react\"\n\nimport { Button } from \"@/components/ui/button\"\nimport { formatCount } from \"@/lib/format-count\"\nimport { cn } from \"@/lib/utils\"\n\ntype JumpBarType = \"unread-above\" | \"new-below\" | \"history\"\n\ntype JumpBarStrings = {\n  /** Unread messages above. `shown` is the count as displayed, capped at 99+. `since` is empty without a time. */\n  unreadAbove: (count: number, shown: string, since: string) => string\n  /** New messages below. */\n  newBelow: (count: number, shown: string) => string\n  /** The history bar's message. */\n  history: string\n  /** The mark as read button. */\n  markRead: string\n  /** The jump to present button. */\n  jumpToPresent: string\n}\n\nconst plural = (count: number) => (count === 1 ? \"message\" : \"messages\")\n\nconst defaultStrings: JumpBarStrings = {\n  unreadAbove: (count, shown, since) =>\n    since\n      ? `${shown} new ${plural(count)} since ${since}`\n      : `${shown} new ${plural(count)}`,\n  newBelow: (count, shown) => `${shown} new ${plural(count)}`,\n  history: \"You're viewing older messages\",\n  markRead: \"Mark as read\",\n  jumpToPresent: \"Jump to present\",\n}\n\nconst MAX_SHOWN = 99\n\ntype JumpBarProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Which bar: unread messages above, new messages below, or reading history. */\n  type: JumpBarType\n  /** How many unread or new messages. Shown up to 99+, read out in full. */\n  count?: number\n  /** When the unread messages begin, for `unread-above`. */\n  since?: Date\n  /** Jumps to the first unread message, the newest message, or the present. */\n  onJump?: () => void\n  /** Marks the conversation read, for `unread-above`. */\n  onMarkRead?: () => void\n  /** Locale for the count and time. */\n  locale?: string\n  /** The bar's copy. Swap it to translate. */\n  strings?: Partial<JumpBarStrings>\n}\n\nfunction JumpBar({\n  type,\n  count = 0,\n  since,\n  onJump,\n  onMarkRead,\n  locale = \"en\",\n  strings: overrides,\n  className,\n  ...props\n}: JumpBarProps) {\n  const strings = { ...defaultStrings, ...overrides }\n  const capped = formatCount(count, { max: MAX_SHOWN, locale })\n  const shown = capped.shown\n  // A capped count keeps the visible words in the name, so speech input can\n  // say what's on screen, and adds the full number after them (WCAG 2.5.3).\n  const exact =\n    count > MAX_SHOWN ? (\n      <span className=\"sr-only\"> ({capped.exact})</span>\n    ) : null\n  const time = since\n    ? new Intl.DateTimeFormat(locale, {\n        hour: \"numeric\",\n        minute: \"2-digit\",\n      }).format(since)\n    : \"\"\n\n  if (type === \"unread-above\") {\n    return (\n      <div\n        data-slot=\"jump-bar\"\n        data-type={type}\n        className={cn(\n          \"@container/jump-bar absolute inset-x-2 top-2 z-10 flex items-center justify-between gap-2 rounded-lg bg-primary p-1 text-xs text-primary-foreground shadow-md\",\n          className\n        )}\n        {...props}\n      >\n        {/* Default buttons on the bar's own primary fill: they show only on\n            hover, in primary-hover. */}\n        {/* Button doesn't shrink by default; this one gives way so Mark as\n            read always fits, and on a narrow bar its label drops the time. */}\n        <Button size=\"xs\" className=\"min-w-0 shrink\" onClick={onJump}>\n          <ArrowUpIcon aria-hidden data-icon=\"inline-start\" />\n          <span\n            className={cn(\"truncate\", time && \"@max-[22rem]/jump-bar:hidden\")}\n          >\n            {strings.unreadAbove(count, shown, time)}\n          </span>\n          {time ? (\n            <span className=\"hidden truncate @max-[22rem]/jump-bar:inline\">\n              {strings.unreadAbove(count, shown, \"\")}\n            </span>\n          ) : null}\n          {exact}\n        </Button>\n        {onMarkRead ? (\n          <Button\n            size=\"xs\"\n            className=\"shrink-0 font-normal\"\n            onClick={onMarkRead}\n          >\n            {strings.markRead}\n          </Button>\n        ) : null}\n      </div>\n    )\n  }\n\n  if (type === \"new-below\") {\n    return (\n      <div\n        data-slot=\"jump-bar\"\n        data-type={type}\n        className={cn(\n          \"pointer-events-none absolute inset-x-0 bottom-3 z-10 flex justify-center\",\n          className\n        )}\n        {...props}\n      >\n        <Button\n          size=\"sm\"\n          className=\"pointer-events-auto text-xs shadow-md\"\n          onClick={onJump}\n        >\n          {strings.newBelow(count, shown)}\n          {exact}\n          <ArrowDownIcon aria-hidden data-icon=\"inline-end\" />\n        </Button>\n      </div>\n    )\n  }\n\n  return (\n    <div\n      data-slot=\"jump-bar\"\n      data-type={type}\n      className={cn(\n        \"absolute inset-x-2 bottom-2 z-10 flex items-center justify-between gap-2 rounded-lg border border-border bg-popover py-1 pr-1 pl-3 text-xs text-popover-foreground shadow-md\",\n        className\n      )}\n      {...props}\n    >\n      {/* Wraps to a second line rather than cutting the sentence short. */}\n      <span className=\"min-w-0 py-1 leading-4 text-balance text-muted-foreground\">\n        {strings.history}\n      </span>\n      <Button variant=\"ghost\" size=\"xs\" className=\"shrink-0\" onClick={onJump}>\n        {strings.jumpToPresent}\n        <ArrowDownIcon aria-hidden data-icon=\"inline-end\" />\n      </Button>\n    </div>\n  )\n}\n\n/*\n * Whether a scroll container sits within `threshold` pixels of its end.\n * Takes the element rather than a ref so it can resubscribe when the\n * element changes: pass it from a callback ref, `ref={setElement}`.\n */\nfunction useAtBottom(element: HTMLElement | null, threshold = 150) {\n  const subscribe = React.useCallback(\n    (notify: () => void) => {\n      if (!element) return () => {}\n      element.addEventListener(\"scroll\", notify, { passive: true })\n      // Content growing underneath moves the end without a scroll event.\n      const resize = new ResizeObserver(notify)\n      resize.observe(element)\n      for (const child of element.children) resize.observe(child)\n      // Messages appended later are new children, which need watching too.\n      const mutations = new MutationObserver((records) => {\n        for (const record of records) {\n          for (const node of record.addedNodes) {\n            if (node instanceof Element) resize.observe(node)\n          }\n          for (const node of record.removedNodes) {\n            if (node instanceof Element) resize.unobserve(node)\n          }\n        }\n        notify()\n      })\n      mutations.observe(element, { childList: true })\n      return () => {\n        element.removeEventListener(\"scroll\", notify)\n        mutations.disconnect()\n        resize.disconnect()\n      }\n    },\n    [element]\n  )\n  return React.useSyncExternalStore(\n    subscribe,\n    () =>\n      !element ||\n      element.scrollHeight - element.scrollTop - element.clientHeight <=\n        threshold,\n    () => true\n  )\n}\n\n/*\n * Scrolls a message into view and focuses it, so focus never stays on a bar\n * that's about to disappear. Smooth for a short hop; instant under reduced\n * motion, and for a long jump, where gliding past screens of history helps\n * nobody.\n */\nfunction jumpTo(\n  target: HTMLElement,\n  {\n    container,\n    block = \"center\",\n  }: {\n    /** The scroll container, to judge how far the jump is. */\n    container?: HTMLElement | null\n    /** Where the target lands in view. */\n    block?: ScrollLogicalPosition\n  } = {}\n) {\n  const reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n  const far = container\n    ? Math.abs(\n        target.getBoundingClientRect().top -\n          container.getBoundingClientRect().top\n      ) >\n      container.clientHeight * 3\n    : false\n  target.scrollIntoView({\n    block,\n    behavior: reduce || far ? \"instant\" : \"smooth\",\n  })\n  target.focus({ preventScroll: true })\n}\n\nexport {\n  JumpBar,\n  useAtBottom,\n  jumpTo,\n  type JumpBarProps,\n  type JumpBarType,\n  type JumpBarStrings,\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/lib/format-count.ts",
      "content": "export type FormatCountOptions = {\n  /** Above this the count shows as `max+`, such as 99+. */\n  max?: number\n  /** `compact` shortens counts from 1,000 up, such as 1.2K. */\n  notation?: \"standard\" | \"compact\"\n  /** Puts a plus before the count, for things not shown, such as +4. */\n  plus?: boolean\n  /** BCP 47 locale for the digits and separators. */\n  locale?: string\n}\n\n/**\n * A count as it should be shown: capped, shortened or signed. The exact\n * number is for speech, so a capped or shortened count says it in full.\n */\nexport function formatCount(\n  value: number,\n  { max, notation = \"standard\", plus = false, locale }: FormatCountOptions = {}\n) {\n  const standard = new Intl.NumberFormat(locale)\n  const exact = standard.format(value)\n  let shown: string\n  if (max !== undefined && value > max) {\n    shown = `${standard.format(max)}+`\n  } else if (notation === \"compact\" && Math.abs(value) >= 1000) {\n    shown = new Intl.NumberFormat(locale, {\n      notation: \"compact\",\n      maximumFractionDigits: 1,\n    }).format(value)\n  } else {\n    shown = exact\n  }\n  return { shown: plus ? `+${shown}` : shown, exact }\n}\n",
      "type": "registry:lib"
    }
  ],
  "meta": {
    "tier": "base-components",
    "group": "Actions",
    "docs": "https://fibo.toribryan.com/?path=/docs/base-components-actions-jump-bar--docs"
  },
  "docs": "Docs and live examples: https://fibo.toribryan.com/?path=/docs/base-components-actions-jump-bar--docs",
  "categories": [
    "base-components",
    "actions"
  ],
  "type": "registry:ui"
}