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