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
@@ -105,6 +105,10 @@ function renderIcon(iconName: string): string {
</div>
<script>
import {
isSearchBoxTypeable,
isTypeToSearchKey,
} from "@snapotter/shared/search/type-to-search.js";
import { matchTool } from "../lib/tool-search";
const input = document.getElementById("hero-tool-search") as HTMLInputElement | null;
@@ -167,5 +171,25 @@ function renderIcon(iconName: string): string {
document.addEventListener("click", (e) => {
if (!box.contains(e.target as Node) && e.target !== input) close();
});
// Start typing anywhere on the page and the hero search picks it up, as long
// as it is actually on screen and nothing else holds focus. Once the input
// has focus this bails out and the browser types normally, which is what
// keeps the caret behaving.
document.addEventListener("keydown", (e) => {
if (!isTypeToSearchKey(e)) 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 an unreachable box.
input.focus();
if (document.activeElement !== input) return;
e.preventDefault();
input.value += e.key;
input.setSelectionRange(input.value.length, input.value.length);
// Drives the existing run(), so filtering, the result cap, firstHref and
// aria-expanded all stay in one place.
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
</script>