From 0e25dc97e0f743e9e313478bfaac1b0cb0786c68 Mon Sep 17 00:00:00 2001 From: Julian Benegas Date: Fri, 24 Apr 2026 19:56:42 +0000 Subject: [PATCH] feat: auto-focus inputs on typing and pasting Add a useAutoFocusOnType hook that automatically focuses the main input/textarea when the user starts typing or pastes text, without stealing focus from other interactive elements. The hook: - Listens for keydown (printable characters only) and paste events - Skips if another input, textarea, select, or contenteditable is focused - Skips if focus is inside a dialog, menu, listbox, combobox, or popup - Skips if modifier keys (Cmd/Ctrl/Alt) are held (Shift is allowed) - For paste, uses native value setter to work with React controlled inputs Applied to: - Composer (textarea on repo page and post page) - RepoListWithSearch (search input on homepage) - RepoSearchInput (standalone search input) --- apps/web/components/composer.tsx | 3 + apps/web/components/repo-list-with-search.tsx | 3 + apps/web/components/repo-search-input.tsx | 3 + apps/web/lib/hooks/use-auto-focus-on-type.ts | 114 ++++++++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 apps/web/lib/hooks/use-auto-focus-on-type.ts diff --git a/apps/web/components/composer.tsx b/apps/web/components/composer.tsx index f7936ef..7c51737 100644 --- a/apps/web/components/composer.tsx +++ b/apps/web/components/composer.tsx @@ -19,6 +19,7 @@ import GeminiIcon from "@/components/icons/gemini" import OpenAIIcon from "@/components/icons/openai" import { Menu } from "@/components/ui/menu" import { authClient } from "@/lib/auth-client" +import { useAutoFocusOnType } from "@/lib/hooks/use-auto-focus-on-type" import { useDialogStore } from "@/lib/stores/dialogs" import { cn } from "@/lib/utils" import { Button } from "./button" @@ -178,6 +179,8 @@ export const Composer = ({ } }, [autoFocus]) + useAutoFocusOnType(textareaRef) + return (
{ const query = value.trim() diff --git a/apps/web/components/repo-search-input.tsx b/apps/web/components/repo-search-input.tsx index 5e9d21d..f1ad4c3 100644 --- a/apps/web/components/repo-search-input.tsx +++ b/apps/web/components/repo-search-input.tsx @@ -4,6 +4,7 @@ import { SearchIcon } from "lucide-react" import Link from "next/link" import { useRouter } from "next/navigation" import { useEffect, useRef, useState } from "react" +import { useAutoFocusOnType } from "@/lib/hooks/use-auto-focus-on-type" function parseRepoInput(input: string): { owner: string; repo: string } | null { const trimmed = input.trim() @@ -42,6 +43,8 @@ export function RepoSearchInput({ autoFocus }: { autoFocus?: boolean }) { } }, [autoFocus]) + useAutoFocusOnType(inputRef) + function handleSubmit(e: React.FormEvent) { e.preventDefault() const parsed = parseRepoInput(value) diff --git a/apps/web/lib/hooks/use-auto-focus-on-type.ts b/apps/web/lib/hooks/use-auto-focus-on-type.ts new file mode 100644 index 0000000..714802f --- /dev/null +++ b/apps/web/lib/hooks/use-auto-focus-on-type.ts @@ -0,0 +1,114 @@ +import { type RefObject, useEffect } from "react" + +/** + * Auto-focuses the target input/textarea when the user starts typing or pastes, + * but only if no other interactive element is currently focused. + * + * This avoids stealing focus from other inputs, textareas, selects, buttons, + * contenteditable elements, or elements inside dialogs/menus. + */ +export function useAutoFocusOnType( + ref: RefObject +) { + useEffect(() => { + function shouldIgnore(): boolean { + const active = document.activeElement + if (!active || active === document.body) { + return false + } + + const tag = active.tagName + if ( + tag === "INPUT" || + tag === "TEXTAREA" || + tag === "SELECT" + ) { + return true + } + + if ((active as HTMLElement).isContentEditable) { + return true + } + + // Don't steal focus from elements inside dialogs, menus, or popovers + if ( + active.closest( + '[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"], [data-popup]' + ) + ) { + return true + } + + return false + } + + function handleKeyDown(e: KeyboardEvent) { + // Don't interfere if the target element already has focus + if (document.activeElement === ref.current) { + return + } + + // Don't intercept if modifier keys are held (except Shift for uppercase) + if (e.metaKey || e.ctrlKey || e.altKey) { + return + } + + // Only intercept printable single characters + if (e.key.length !== 1) { + return + } + + if (shouldIgnore()) { + return + } + + ref.current?.focus() + // Don't prevent default — the character will be typed into the now-focused element + } + + function handlePaste(e: ClipboardEvent) { + if (document.activeElement === ref.current) { + return + } + + if (shouldIgnore()) { + return + } + + const text = e.clipboardData?.getData("text") + if (!text) { + return + } + + e.preventDefault() + + const el = ref.current + if (!el) return + + el.focus() + + // Use native value setter + input event to work with React controlled components. + // React overrides the value property on inputs, so we need to call the native + // HTMLInputElement/HTMLTextAreaElement setter to bypass React's controlled value, + // then dispatch an input event so React's onChange fires. + const proto = + el instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype + const nativeSetter = Object.getOwnPropertyDescriptor(proto, "value")?.set + if (nativeSetter) { + const currentValue = el.value + nativeSetter.call(el, currentValue + text) + el.dispatchEvent(new Event("input", { bubbles: true })) + } + } + + document.addEventListener("keydown", handleKeyDown) + document.addEventListener("paste", handlePaste) + + return () => { + document.removeEventListener("keydown", handleKeyDown) + document.removeEventListener("paste", handlePaste) + } + }, [ref]) +}