feat(search): start typing anywhere to fill the search box (#644)

Type a printable character on the landing homepage or the app's home
dashboard and it lands in the search box, provided the box is on screen
and nothing else holds focus. Mod+K keeps working unchanged.

The parts that are easy to get wrong live in
packages/shared/src/search/type-to-search.ts so the two surfaces cannot
drift. isTypeToSearchKey decides whether a keystroke is text.
isSearchBoxTypeable decides whether the box is reachable, via one
elementFromPoint hit test at its center, which folds off-screen,
covered-by-a-modal and hidden into a single check that leans on no one's
aria markup. It fails closed where there is no layout engine, so jsdom
tests that mount the search bar do not blow up on it.

Modifier handling reads getModifierState("AltGraph") rather than
inferring AltGr from ctrl+alt. That inference reads correctly on Windows
and is backwards on macOS, where Option alone types accented characters
and ctrl+alt is a shortcut prefix, VoiceOver's included.

Focus is claimed before the keystroke is committed. Browsers silently
refuse focus inside inert or visibility:hidden subtrees, and without the
check an entire query drains into a box the user cannot see.

Scope comes from where the hook is mounted rather than a route check that
could rot, so tool pages, the editor, Files and Automate get nothing. No
new i18n strings, and no new analytics event, since
ANALYTICS_EVENTS.SEARCH already fires off the same state change.

Verified: 44 new unit tests, full unit suite 7557 passed, landing
homepage 24/24, home-page 19/19, gui-keyboard 41/41, typecheck and lint
clean, all 18 CI checks green.
This commit is contained in:
SnapOtter
2026-07-26 08:27:17 +08:00
committed by GitHub
parent a7137958a1
commit 0058fc610f
8 changed files with 627 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
import { isSearchBoxTypeable, isTypeToSearchKey } from "@snapotter/shared/search/type-to-search.js";
import { type RefObject, useEffect, useRef } from "react";
/**
* Lets someone start typing anywhere on the page and have it land in a search
* box, provided the box is on screen and nothing else holds focus.
*
* Scope comes from where this hook is mounted rather than from a route check, so
* it cannot drift out of step with the UI: mount it next to a search input and
* that page gets the behavior, and no other page does.
*/
export function useTypeToSearch(
inputRef: RefObject<HTMLInputElement | null>,
onChange: (next: string) => void,
) {
// The listener is registered once, so an inline onChange would otherwise be
// captured from first render and never updated.
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
});
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
const input = inputRef.current;
if (!input) return;
if (!isTypeToSearchKey(event)) return;
if (!isSearchBoxTypeable(input, document)) return;
// Focus before committing to the keystroke. The browser silently refuses
// focus inside an inert or visibility:hidden subtree, and swallowing the
// character there would drop a whole query into a box nobody can see.
// Returning without preventDefault leaves the key to the browser.
input.focus();
if (document.activeElement !== input) return;
event.preventDefault();
// The input is controlled, so its DOM value is the committed React state.
// That is a race-free source; a ref synced in a passive effect can lag the
// DOM by a keystroke if the next keydown is serviced before the flush.
onChangeRef.current(input.value + event.key);
// React writes the new value on the next commit, so the caret has to be
// placed after that lands. Appending to an existing query would otherwise
// leave it at the start and put the following character in front.
requestAnimationFrame(() => {
const end = input.value.length;
input.setSelectionRange(end, end);
});
}
// Bubble phase with no stopPropagation, so use-keyboard-shortcuts keeps
// first refusal on every Mod+ combination.
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [inputRef]);
}
+3
View File
@@ -10,6 +10,7 @@ import { useTranslation } from "@/contexts/i18n-context";
import { useFuseSearch } from "@/hooks/use-fuse-search.js";
import { usePageTitle } from "@/hooks/use-page-title.js";
import { useRecentTools } from "@/hooks/use-recent-tools.js";
import { useTypeToSearch } from "@/hooks/use-type-to-search.js";
import type { FeedbackPromptVariant } from "@/lib/feedback.js";
import { trackFeedbackPromptDismissed, trackFeedbackPromptShown } from "@/lib/feedback.js";
import { format } from "@/lib/format.js";
@@ -237,6 +238,8 @@ function HomeSearchBar({
const location = useLocation();
const navigate = useNavigate();
useTypeToSearch(inputRef, onChange);
useEffect(() => {
const params = new URLSearchParams(location.search);
if (params.get("focus") === "search") {