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