{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "alphabet-index",
  "type": "registry:ui",
  "title": "Alphabet Index",
  "description": "An A–Z scrubber beside a long grouped list: drag to jump with a letter bubble, sticky headers, locale-aware sorting.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/alphabet-index.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface AlphabetIndexItem {\n  id: string;\n  /** Sorted with Intl.Collator and grouped by its first letter. */\n  name: string;\n}\n\nexport interface AlphabetIndexProps<T extends AlphabetIndexItem> extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  items: T[];\n  /** Custom row content. Defaults to an initials avatar and the name. */\n  renderItem?: (item: T) => React.ReactNode;\n  /** Locale for sorting and upper-casing (deterministic on server and client). */\n  locale?: string;\n  /** Index letters in order. Names that start with anything else are grouped under \"#\". */\n  letters?: string[];\n  /** Accessible name of the list. */\n  label?: string;\n  /** Large letter bubble beside the index while scrubbing. */\n  showBubble?: boolean;\n  /** Letters without entries are dimmed (and skipped) or hidden. */\n  emptyLetters?: \"dim\" | \"hide\";\n  /** Section headers stick to the top of the scroll area. */\n  stickyHeaders?: boolean;\n  /** Which side the index sits on. */\n  indexSide?: \"right\" | \"left\";\n  /**\n   * An external scroll container (e.g. a scrolling page section). By default the list scrolls itself and\n   * fills its parent's height.\n   */\n  container?: React.RefObject<HTMLElement | null>;\n  /** Accessible count text for a letter. */\n  countLabel?: (count: number) => string;\n  /** Active letter, bubble and focus colour. */\n  accent?: string;\n}\n\ninterface Group<T> {\n  letter: string;\n  items: T[];\n}\n\nconst A_TO_Z = Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i));\nconst OTHER = \"#\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst initials = (name: string) =>\n  name\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((part) => part.charAt(0))\n    .join(\"\")\n    .toUpperCase();\n\n/**\n * A long grouped list with an A–Z index scrubber. Items are sorted with Intl.Collator and grouped by first\n * letter (diacritics fold onto their base letter unless the alphabet lists them). The index is a vertical\n * list of buttons with a single tab stop: arrows preview a letter, Enter jumps and moves focus to the\n * section header, and dragging along it scrubs with a large letter bubble.\n */\nexport function AlphabetIndex<T extends AlphabetIndexItem>({\n  items,\n  renderItem,\n  locale = \"en\",\n  letters = A_TO_Z,\n  label = \"Contacts\",\n  showBubble = true,\n  emptyLetters = \"dim\",\n  stickyHeaders = true,\n  indexSide = \"right\",\n  container,\n  countLabel = (n) => `${n} ${n === 1 ? \"item\" : \"items\"}`,\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  ...props\n}: AlphabetIndexProps<T>) {\n  const uid = React.useId();\n  const reduceMotion = usePrefersReducedMotion();\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const indexRef = React.useRef<HTMLUListElement>(null);\n  const bubbleRef = React.useRef<HTMLDivElement>(null);\n  const headerRefs = React.useRef(new Map<string, HTMLHeadingElement>());\n  const buttonRefs = React.useRef(new Map<string, HTMLButtonElement>());\n  const scrubRef = React.useRef<{ pointerId: number; letter: string | null } | null>(null);\n  const flashTimer = React.useRef(0);\n\n  /* --------------------------------- grouping -------------------------------- */\n\n  const collator = new Intl.Collator(locale, { sensitivity: \"base\", numeric: true });\n  const letterSet = new Set(letters);\n  const groupOf = (name: string) => {\n    const first = name.trim().charAt(0).toLocaleUpperCase(locale);\n    if (letterSet.has(first)) return first;\n    const base = first.normalize(\"NFD\").replace(/\\p{M}/gu, \"\");\n    return letterSet.has(base) ? base : OTHER;\n  };\n  const buckets = new Map<string, T[]>();\n  for (const item of [...items].sort((a, b) => collator.compare(a.name, b.name))) {\n    const key = groupOf(item.name);\n    const bucket = buckets.get(key);\n    if (bucket) bucket.push(item);\n    else buckets.set(key, [item]);\n  }\n  const alphabet = [...letters, OTHER];\n  const groups: Group<T>[] = alphabet.flatMap((letter) => {\n    const bucket = buckets.get(letter);\n    return bucket ? [{ letter, items: bucket }] : [];\n  });\n  const counts = new Map(groups.map((g) => [g.letter, g.items.length]));\n  const shown = emptyLetters === \"hide\" ? alphabet.filter((l) => counts.has(l)) : alphabet;\n  const available = shown.filter((l) => counts.has(l));\n\n  const [activeLetter, setActiveLetter] = React.useState<string | null>(null);\n  const [bubble, setBubble] = React.useState<{ letter: string; visible: boolean }>({ letter: \"\", visible: false });\n  const [focusLetter, setFocusLetter] = React.useState<string | null>(null);\n  const current = activeLetter ?? groups[0]?.letter ?? null;\n  const tabStop = focusLetter && counts.has(focusLetter) ? focusLetter : (current ?? available[0]);\n\n  /* --------------------------------- scrolling -------------------------------- */\n\n  const getScroller = () => container?.current ?? listRef.current;\n\n  /** Scrolls the list (never the page) so the section starts at the top of the scroll area. */\n  const jump = (letter: string) => {\n    const scroller = getScroller();\n    const header = headerRefs.current.get(letter);\n    if (!scroller || !header) return;\n    const section = header.parentElement ?? header;\n    const top = section.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop;\n    scroller.scrollTop = Math.max(0, Math.round(top));\n    setActiveLetter(letter);\n  };\n\n  const commit = (letter: string) => {\n    jump(letter);\n    headerRefs.current.get(letter)?.focus({ preventScroll: true });\n  };\n\n  /** Nearest letter with entries, searching forward first (like iOS). */\n  const nearestAvailable = (letter: string) => {\n    if (counts.has(letter)) return letter;\n    const i = alphabet.indexOf(letter);\n    for (let d = 1; d < alphabet.length; d++) {\n      if (counts.has(alphabet[i + d])) return alphabet[i + d];\n      if (counts.has(alphabet[i - d])) return alphabet[i - d];\n    }\n    return null;\n  };\n\n  // Track the section at the top of the scroll area (rAF-throttled; state only when it changes).\n  React.useEffect(() => {\n    const scroller = container?.current ?? listRef.current;\n    if (!scroller) return;\n    let frame = 0;\n    const measure = () => {\n      frame = 0;\n      const top = scroller.getBoundingClientRect().top;\n      let found: string | null = null;\n      const sections = listRef.current?.querySelectorAll<HTMLElement>(\"section[data-letter]\") ?? [];\n      for (const section of sections) {\n        if (section.getBoundingClientRect().top - top <= 1) found = section.dataset.letter ?? null;\n        else break;\n      }\n      // At the very bottom the last sections can never reach the top: treat the last one as current.\n      if (sections.length && scroller.scrollTop > 0 && scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 1) {\n        found = sections[sections.length - 1].dataset.letter ?? found;\n      }\n      setActiveLetter((prev) => (prev === found ? prev : found));\n    };\n    const onScroll = () => {\n      if (!frame) frame = requestAnimationFrame(measure);\n    };\n    scroller.addEventListener(\"scroll\", onScroll, { passive: true });\n    return () => {\n      scroller.removeEventListener(\"scroll\", onScroll);\n      cancelAnimationFrame(frame);\n    };\n  }, [container]);\n\n  // External container: keep the index as tall as the container's visible area.\n  const [indexHeight, setIndexHeight] = React.useState<number | null>(null);\n  React.useEffect(() => {\n    const el = container?.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => setIndexHeight(el.clientHeight));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [container]);\n\n  React.useEffect(() => () => window.clearTimeout(flashTimer.current), []);\n\n  // Short indexes (under ~13px per letter) condense like iOS: every other letter becomes a dot.\n  const [condensed, setCondensed] = React.useState(false);\n  const shownCount = shown.length;\n  React.useEffect(() => {\n    const index = indexRef.current;\n    if (!index) return;\n    const ro = new ResizeObserver(() => setCondensed(shownCount > 0 && index.clientHeight / shownCount < 13));\n    ro.observe(index);\n    return () => ro.disconnect();\n  }, [shownCount]);\n\n  /* --------------------------------- bubble ---------------------------------- */\n\n  const placeBubble = (y: number) => {\n    const bubbleEl = bubbleRef.current;\n    const index = indexRef.current;\n    if (!bubbleEl || !index) return;\n    const rect = index.getBoundingClientRect();\n    const clamped = Math.max(0, Math.min(rect.height, y - rect.top));\n    bubbleEl.style.translate = `0 ${Math.round(clamped + index.offsetTop)}px`;\n  };\n\n  const flash = (letter: string) => {\n    if (!showBubble) return;\n    const button = buttonRefs.current.get(letter);\n    const index = indexRef.current;\n    if (button && index) {\n      const rect = button.getBoundingClientRect();\n      placeBubble(rect.top + rect.height / 2);\n    }\n    setBubble({ letter, visible: true });\n    window.clearTimeout(flashTimer.current);\n    flashTimer.current = window.setTimeout(() => setBubble((b) => ({ ...b, visible: false })), 650);\n  };\n\n  /* ---------------------------------- pointer --------------------------------- */\n\n  const letterAt = (clientY: number) => {\n    const index = indexRef.current;\n    if (!index || !shown.length) return null;\n    const rect = index.getBoundingClientRect();\n    const i = Math.floor(((clientY - rect.top) / rect.height) * shown.length);\n    return shown[Math.max(0, Math.min(shown.length - 1, i))];\n  };\n\n  const scrubTo = (clientY: number) => {\n    const scrub = scrubRef.current;\n    const letter = letterAt(clientY);\n    placeBubble(clientY);\n    if (!scrub || !letter || letter === scrub.letter) return;\n    scrub.letter = letter;\n    if (showBubble) setBubble({ letter, visible: true });\n    const target = nearestAvailable(letter);\n    if (target) jump(target);\n  };\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLUListElement>) => {\n    if (event.button !== 0) return;\n    // Keep focus where it is and avoid text selection; the release decides where focus goes.\n    event.preventDefault();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    window.clearTimeout(flashTimer.current);\n    scrubRef.current = { pointerId: event.pointerId, letter: null };\n    scrubTo(event.clientY);\n  };\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLUListElement>) => {\n    if (scrubRef.current?.pointerId === event.pointerId) scrubTo(event.clientY);\n  };\n\n  const endScrub = (event: React.PointerEvent<HTMLUListElement>, cancelled: boolean) => {\n    const scrub = scrubRef.current;\n    if (!scrub || scrub.pointerId !== event.pointerId) return;\n    scrubRef.current = null;\n    setBubble((b) => ({ ...b, visible: false }));\n    const target = scrub.letter ? nearestAvailable(scrub.letter) : null;\n    if (!cancelled && target) {\n      setFocusLetter(target);\n      commit(target);\n    }\n  };\n\n  /* --------------------------------- keyboard --------------------------------- */\n\n  const moveFocus = (letter: string | undefined | null) => {\n    if (!letter) return;\n    setFocusLetter(letter);\n    buttonRefs.current.get(letter)?.focus();\n    jump(letter);\n    flash(letter);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {\n    const from = (event.target as HTMLElement).closest<HTMLElement>(\"[data-letter]\")?.dataset.letter;\n    if (!from) return;\n    const i = available.indexOf(from);\n    const at = (n: number) => available[Math.max(0, Math.min(available.length - 1, n))];\n    const keys: Record<string, () => void> = {\n      ArrowDown: () => moveFocus(at(i + 1)),\n      ArrowRight: () => moveFocus(at(i + 1)),\n      ArrowUp: () => moveFocus(at(i - 1)),\n      ArrowLeft: () => moveFocus(at(i - 1)),\n      PageDown: () => moveFocus(at(i + 5)),\n      PageUp: () => moveFocus(at(i - 5)),\n      Home: () => moveFocus(available[0]),\n      End: () => moveFocus(available[available.length - 1]),\n    };\n    const action = keys[event.key];\n    if (action) {\n      event.preventDefault();\n      action();\n      return;\n    }\n    // Typing a letter jumps to it.\n    if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {\n      const typed = event.key.toLocaleUpperCase(locale);\n      if (counts.has(typed)) {\n        event.preventDefault();\n        moveFocus(typed);\n      }\n    }\n  };\n\n  /* ---------------------------------- render ---------------------------------- */\n\n  const ring =\n    \"outline-hidden focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-[var(--ai-accent)]\";\n  const left = indexSide === \"left\";\n  const internal = !container;\n\n  const index = (\n    <nav\n      aria-label={`${label} index`}\n      className={`relative flex w-7 shrink-0 select-none flex-col py-2 ${internal ? \"h-full\" : \"sticky top-0 self-start\"}`}\n      style={!internal && indexHeight ? { height: indexHeight } : undefined}\n    >\n      <ul\n        ref={indexRef}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={(event) => endScrub(event, false)}\n        onPointerCancel={(event) => endScrub(event, true)}\n        onKeyDown={handleKeyDown}\n        className=\"flex min-h-0 flex-1 cursor-pointer touch-none flex-col items-center justify-between\"\n      >\n        {shown.map((letter, position) => {\n          const count = counts.get(letter) ?? 0;\n          const asDot = condensed && position % 2 === 1 && letter !== current;\n          const empty = count === 0;\n          const isCurrent = letter === current;\n          return (\n            <li key={letter} className=\"flex min-h-0 flex-1 items-center\">\n              <button\n                ref={(el) => {\n                  if (el) buttonRefs.current.set(letter, el);\n                  else buttonRefs.current.delete(letter);\n                }}\n                type=\"button\"\n                data-letter={letter}\n                disabled={empty}\n                tabIndex={letter === tabStop ? 0 : -1}\n                aria-label={`${letter}, ${countLabel(count)}`}\n                aria-current={isCurrent ? \"location\" : undefined}\n                aria-controls={`${uid}-list`}\n                onClick={(event) => {\n                  // Pointer presses are handled by the scrub handlers; this is Enter / Space.\n                  if (event.detail === 0) {\n                    setFocusLetter(letter);\n                    commit(letter);\n                  }\n                }}\n                className={`flex h-full max-h-6 min-h-0 w-6 items-center justify-center rounded-md font-mono text-[10px] font-medium leading-none tabular-nums transition-colors disabled:cursor-default ${\n                  isCurrent\n                    ? \"text-[var(--ai-accent)]\"\n                    : empty\n                      ? \"text-zinc-900/20 dark:text-white/20\"\n                      : \"text-zinc-600 hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-white\"\n                } ${ring}`}\n              >\n                {asDot ? <span aria-hidden=\"true\" className=\"size-[3px] rounded-full bg-current\" /> : letter}\n              </button>\n            </li>\n          );\n        })}\n      </ul>\n\n      {showBubble && (\n        <div\n          ref={bubbleRef}\n          aria-hidden=\"true\"\n          className={`pointer-events-none absolute top-0 z-20 ${left ? \"left-full ml-3\" : \"right-full mr-3\"} ${\n            reduceMotion ? \"\" : \"transition-[opacity,scale] duration-150\"\n          } ${bubble.visible ? \"scale-100 opacity-100\" : \"scale-75 opacity-0\"}`}\n        >\n          <div\n            className={`-mt-7 flex size-14 items-center justify-center rounded-2xl border bg-white text-2xl font-semibold tracking-tight shadow-[0_12px_32px_-12px_rgba(0,0,0,0.35)] dark:bg-zinc-800 dark:shadow-[0_12px_32px_-12px_rgba(0,0,0,0.9)] ${\n              counts.has(bubble.letter)\n                ? \"border-[var(--ai-accent)] text-zinc-950 dark:text-white\"\n                : \"border-zinc-900/10 text-zinc-400 dark:border-white/10 dark:text-zinc-500\"\n            }`}\n          >\n            {bubble.letter}\n          </div>\n        </div>\n      )}\n    </nav>\n  );\n\n  return (\n    <div\n      className={`relative flex min-h-0 ${internal ? \"h-full\" : \"\"} ${left ? \"flex-row-reverse\" : \"\"} ${className}`}\n      style={{ [\"--ai-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <div\n        ref={listRef}\n        id={`${uid}-list`}\n        role=\"region\"\n        aria-label={label}\n        tabIndex={internal ? 0 : undefined}\n        className={`relative min-h-0 min-w-0 flex-1 ${internal ? \"overflow-y-auto overscroll-contain\" : \"\"} ${ring} focus-visible:outline-offset-[-2px]`}\n      >\n        {groups.map((group) => (\n          <section key={group.letter} data-letter={group.letter} aria-labelledby={`${uid}-h-${group.letter}`}>\n            <h3\n              ref={(el) => {\n                if (el) headerRefs.current.set(group.letter, el);\n                else headerRefs.current.delete(group.letter);\n              }}\n              id={`${uid}-h-${group.letter}`}\n              tabIndex={-1}\n              className={`${stickyHeaders ? \"sticky top-0 z-10\" : \"\"} flex items-baseline gap-2 border-b border-zinc-900/[0.07] bg-white/95 px-4 py-1.5 font-mono text-[10px] font-medium uppercase tracking-[0.14em] text-zinc-500 backdrop-blur-sm dark:border-white/[0.07] dark:bg-zinc-900/95 dark:text-zinc-400 ${ring} focus-visible:outline-offset-[-2px]`}\n            >\n              <span className={group.letter === current ? \"text-[var(--ai-accent)]\" : \"text-zinc-900 dark:text-zinc-100\"}>{group.letter}</span>\n              <span className=\"tabular-nums\">{group.items.length}</span>\n            </h3>\n            <ul>\n              {group.items.map((item) => (\n                <li key={item.id} className=\"border-b border-zinc-900/[0.05] last:border-0 dark:border-white/[0.05]\">\n                  {renderItem ? (\n                    renderItem(item)\n                  ) : (\n                    <div className=\"flex items-center gap-3 px-4 py-2.5\">\n                      <span\n                        aria-hidden=\"true\"\n                        className=\"flex size-8 shrink-0 items-center justify-center rounded-full bg-zinc-900/[0.06] font-mono text-[10px] text-zinc-600 dark:bg-white/[0.08] dark:text-zinc-300\"\n                      >\n                        {initials(item.name)}\n                      </span>\n                      <span className=\"truncate text-sm text-zinc-900 dark:text-zinc-100\">{item.name}</span>\n                    </div>\n                  )}\n                </li>\n              ))}\n            </ul>\n          </section>\n        ))}\n        {groups.length === 0 && <p className=\"px-4 py-8 text-center text-sm text-zinc-500\">Nothing to show.</p>}\n      </div>\n      {index}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Documentation: https://wancraft.my/components/alphabet-index"
}
