"use client";
import * as React from "react";
export interface LinkHintsProps extends React.HTMLAttributes<HTMLDivElement> {
/** The scoped content. Only focusable elements inside it get hints. */
children: React.ReactNode;
/** Key that shows the hints while focus is inside the container (never while typing). */
triggerKey?: string;
/** Characters used to build the hint labels. */
alphabet?: string;
/** "activate" focuses and clicks the element; "focus" only focuses it. Text fields are always just focused. */
action?: "activate" | "focus";
/** Show the "Show hints" button above the content. */
showButton?: boolean;
buttonLabel?: string;
/** Display labels in capitals (typing is case-insensitive either way). */
uppercase?: boolean;
variant?: "accent" | "contrast";
/** Which elements get hints. */
selector?: string;
/** Called after a hint was used, with the element it targeted. */
onHint?: (element: HTMLElement) => void;
accent?: string;
}
interface Hint {
el: HTMLElement;
label: string;
}
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex="-1"]), [contenteditable=""], [contenteditable="true"]';
const TEXT_ENTRY = 'input:not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="range"]):not([type="color"]):not([type="file"]), textarea, select, [contenteditable=""], [contenteditable="true"]';
/** Vimium-style prefix-free labels: short ones where possible, never one label a prefix of another. */
export function hintLabels(count: number, alphabet: string): string[] {
const chars = Array.from(new Set(alphabet.toLowerCase().replace(/\s/g, "")));
if (count <= 0 || chars.length < 2) return [];
const hints = [""];
let offset = 0;
while (hints.length - offset < count || hints.length === 1) {
const hint = hints[offset++];
for (const ch of chars) hints.push(ch + hint);
}
return hints
.slice(offset, offset + count)
.sort()
.map((h) => Array.from(h).reverse().join(""));
}
function isTyping(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
if (target.matches(TEXT_ENTRY)) return true;
const role = target.getAttribute("role");
return role === "textbox" || role === "combobox" || role === "searchbox" || role === "spinbutton";
}
/** Visible, enabled and not covered by something else. */
function isHintable(el: HTMLElement, bounds: DOMRect) {
if (el.closest('[inert], [aria-hidden="true"], [hidden]')) return false;
const r = el.getBoundingClientRect();
if (r.width < 1 || r.height < 1) return false;
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const left = Math.max(r.left, bounds.left, 0);
const top = Math.max(r.top, bounds.top, 0);
const right = Math.min(r.right, bounds.right, vw);
const bottom = Math.min(r.bottom, bounds.bottom, vh);
if (right - left < 1 || bottom - top < 1) return false;
const style = getComputedStyle(el);
if (style.visibility === "hidden" || style.pointerEvents === "none") return false;
const hit = document.elementFromPoint((left + right) / 2, (top + bottom) / 2);
return !!hit && (el === hit || el.contains(hit) || hit.contains(el));
}
/**
* Vimium-style keyboard hints, scoped to one container. Press the trigger key (default "f") while
* focus is inside it, or use the button, and every visible focusable element gets a short letter
* label in an overlay. Type a label to activate (or focus) that element; Backspace edits, Escape
* cancels. Labels follow scroll and resize. Never global: nothing listens outside the container
* until the hints are showing.
*/
export function LinkHints({
children,
triggerKey = "f",
alphabet = "sadfjklewcmpgh",
action = "activate",
showButton = true,
buttonLabel = "Show hints",
uppercase = true,
variant = "accent",
selector = FOCUSABLE,
onHint,
accent = "#FF7448",
className = "",
style,
onKeyDown,
...props
}: LinkHintsProps) {
const [hints, setHints] = React.useState<Hint[] | null>(null);
const [typed, setTyped] = React.useState("");
const [announcement, setAnnouncement] = React.useState("");
const [miss, setMiss] = React.useState(0);
const containerRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const overlayRef = React.useRef<HTMLDivElement>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const active = hints !== null;
const show = () => {
const content = contentRef.current;
const container = containerRef.current;
if (!content || !container) return;
const bounds = container.getBoundingClientRect();
const targets = Array.from(content.querySelectorAll<HTMLElement>(selector)).filter((el) => isHintable(el, bounds));
const labels = hintLabels(targets.length, alphabet);
if (!labels.length) {
setAnnouncement("No hints available");
return;
}
setTyped("");
setHints(targets.map((el, i) => ({ el, label: labels[i] })));
setAnnouncement(`${targets.length} hints shown. Type the letters to choose, Escape to cancel.`);
};
const hide = React.useCallback((message = "Hints hidden") => {
setHints(null);
setTyped("");
setAnnouncement(message);
}, []);
/** Puts each label at its element's top-left corner, clamped to the viewport and the container. */
const position = React.useCallback(() => {
const overlay = overlayRef.current;
const container = containerRef.current;
if (!overlay || !container || !hints) return;
const bounds = container.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
overlay.querySelectorAll<HTMLElement>("[data-hint]").forEach((label) => {
const hint = hints[Number(label.dataset.hint)];
if (!hint) return;
const r = hint.el.getBoundingClientRect();
const visible = r.bottom > Math.max(0, bounds.top) && r.top < Math.min(vh, bounds.bottom) && r.right > 0 && r.left < vw;
label.style.visibility = visible ? "" : "hidden";
const x = Math.max(2, Math.min(r.left - 4, vw - label.offsetWidth - 2));
const y = Math.max(2, Math.min(r.top - 6, vh - label.offsetHeight - 2));
label.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
});
}, [hints]);
// Overlay in the top layer while hints are showing.
React.useLayoutEffect(() => {
const overlay = overlayRef.current;
if (!overlay) return;
if (active) {
try {
if (!overlay.matches(":popover-open")) overlay.showPopover();
} catch {
// No Popover API: the fixed overlay still covers the viewport.
}
} else {
try {
overlay.hidePopover();
} catch {
// not open
}
}
}, [active]);
// Re-place labels after every render while showing (Backspace can bring filtered labels back).
React.useLayoutEffect(() => {
if (active) position();
});
// A key that matches no label: a tiny shake (skipped with reduced motion).
React.useEffect(() => {
const overlay = overlayRef.current;
if (!miss || !overlay || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
overlay.animate([{ translate: "0 0" }, { translate: "-3px 0" }, { translate: "3px 0" }, { translate: "0 0" }], { duration: 180, easing: "ease-out" });
}, [miss]);
// Latest state for the window listeners below.
const live = React.useRef({ hints, typed });
React.useEffect(() => {
live.current = { hints, typed };
});
const choose = React.useCallback(
(hint: Hint) => {
const el = hint.el;
setHints(null);
setTyped("");
if (!el.isConnected) return;
el.focus({ preventScroll: true });
if (action === "activate" && !el.matches(TEXT_ENTRY) && !el.isContentEditable) el.click();
setAnnouncement("");
onHint?.(el);
},
[action, onHint],
);
// While showing: capture the keyboard (Escape closes only this layer), follow scroll / resize, close on outside press.
React.useEffect(() => {
if (!active) return;
let frame = 0;
const onMove = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(position);
};
const onKey = (event: KeyboardEvent) => {
if (event.isComposing) return;
const { hints: current, typed: buffer } = live.current;
if (!current) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
hide();
return;
}
if (event.ctrlKey || event.metaKey || event.altKey) {
hide("");
return;
}
if (event.key === "Backspace") {
event.preventDefault();
event.stopPropagation();
setTyped(buffer.slice(0, -1));
return;
}
if (event.key.length !== 1) {
if (event.key === "Shift") return;
// Tab, arrows and friends: give the key back to the page.
hide("");
return;
}
// Every printable key is ours while hints show (so it can't trigger page shortcuts).
event.preventDefault();
event.stopPropagation();
const next = buffer + event.key.toLowerCase();
const matches = current.filter((h) => h.label.startsWith(next));
if (!matches.length) {
setMiss((n) => n + 1);
return;
}
const exact = matches.find((h) => h.label === next);
if (exact) choose(exact);
else setTyped(next);
};
const onPointerDown = (event: PointerEvent) => {
if (buttonRef.current?.contains(event.target as Node)) return;
hide("");
};
window.addEventListener("keydown", onKey, true);
window.addEventListener("pointerdown", onPointerDown, true);
window.addEventListener("scroll", onMove, { capture: true, passive: true });
window.addEventListener("resize", onMove);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("keydown", onKey, true);
window.removeEventListener("pointerdown", onPointerDown, true);
window.removeEventListener("scroll", onMove, { capture: true });
window.removeEventListener("resize", onMove);
};
}, [active, position, choose, hide]);
const key = triggerKey.toLowerCase();
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented || active || event.ctrlKey || event.metaKey || event.altKey || event.repeat) return;
if (event.key.toLowerCase() !== key || isTyping(event.target)) return;
event.preventDefault();
show();
};
const pill =
variant === "contrast"
? "bg-zinc-900 text-white ring-1 ring-white/20 dark:bg-white dark:text-zinc-900 dark:ring-zinc-900/20"
: "bg-[var(--lh-accent)] text-zinc-950 ring-1 ring-zinc-950/25";
return (
<div
ref={containerRef}
tabIndex={-1}
onKeyDown={handleKeyDown}
className={`relative outline-hidden ${className}`}
style={{ ["--lh-accent" as string]: accent, ...style }}
{...props}
>
{showButton ? (
<div className="mb-3 flex justify-end">
<button
ref={buttonRef}
type="button"
aria-pressed={active}
aria-keyshortcuts={triggerKey}
onClick={() => (active ? hide() : show())}
className="inline-flex h-8 items-center gap-2 rounded-lg border border-zinc-900/12 bg-white px-2.5 text-[12.5px] font-medium text-zinc-800 outline-hidden transition-colors hover:bg-zinc-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--lh-accent)] aria-pressed:border-[var(--lh-accent)] dark:border-white/12 dark:bg-[#1a1d21] dark:text-zinc-100 dark:hover:bg-[#20242a]"
>
{buttonLabel}
<kbd className="grid h-5 min-w-5 place-items-center rounded border border-zinc-900/15 px-1 font-mono text-[10px] uppercase text-zinc-500 dark:border-white/15 dark:text-zinc-400">{triggerKey}</kbd>
</button>
</div>
) : null}
<div ref={contentRef}>{children}</div>
{/* Decorative overlay: labels mirror what is announced; assistive tech gets the live region. */}
<div ref={overlayRef} popover="manual" aria-hidden="true" className="pointer-events-none fixed inset-0 m-0 h-full max-h-none w-full max-w-none overflow-visible border-0 bg-transparent p-0">
{hints?.map((hint, i) => {
const matching = hint.label.startsWith(typed);
if (!matching) return null;
return (
<span
key={hint.label}
data-hint={i}
className={`absolute left-0 top-0 inline-flex h-[18px] items-center rounded-[5px] px-1 font-mono text-[11px] font-semibold leading-none tracking-[0.04em] shadow-[0_4px_12px_-4px_rgb(0_0_0/0.45)] ${pill} ${uppercase ? "uppercase" : ""}`}
>
<span className="opacity-45">{hint.label.slice(0, typed.length)}</span>
{hint.label.slice(typed.length)}
</span>
);
})}
</div>
<p className="sr-only" aria-live="polite" aria-atomic="true">
{announcement}
</p>
</div>
);
}