mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Keystroke routing for "start typing anywhere to search".
|
||||
*
|
||||
* Shared by the landing hero search and the app's home dashboard so the two
|
||||
* surfaces cannot drift on the parts that are easy to get wrong: modifier
|
||||
* handling, IME composition, and deciding whether the search box is genuinely
|
||||
* available.
|
||||
*
|
||||
* Both functions are typed structurally rather than against DOM lib types, so
|
||||
* they take a real KeyboardEvent/Element/Document at runtime while staying
|
||||
* testable in the node environment that unit tests default to.
|
||||
*/
|
||||
|
||||
export interface TypeToSearchKeyEvent {
|
||||
key: string;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
altKey: boolean;
|
||||
/**
|
||||
* Never read. Shift is already folded into `key` by the layout, so a capital
|
||||
* arrives as "A". Declared so tests can pin that it stays ignored, because a
|
||||
* plausible-looking `&& !shiftKey` tightening would break capital AltGr
|
||||
* characters in pl, tr and de.
|
||||
*/
|
||||
shiftKey?: boolean;
|
||||
isComposing?: boolean;
|
||||
defaultPrevented?: boolean;
|
||||
getModifierState?: (key: string) => boolean;
|
||||
}
|
||||
|
||||
export interface TypeToSearchTarget {
|
||||
getBoundingClientRect(): { left: number; top: number; width: number; height: number };
|
||||
contains(other: unknown): boolean;
|
||||
}
|
||||
|
||||
export interface TypeToSearchDocument {
|
||||
body: unknown;
|
||||
activeElement: unknown;
|
||||
elementFromPoint?: (x: number, y: number) => unknown;
|
||||
}
|
||||
|
||||
/** Would this keystroke be someone starting to type a search query? */
|
||||
export function isTypeToSearchKey(event: TypeToSearchKeyEvent): boolean {
|
||||
if (event.defaultPrevented) return false;
|
||||
if (event.isComposing) return false;
|
||||
|
||||
// Every non-printable key reports a multi-character name: Enter, Tab, Escape,
|
||||
// ArrowDown, F1, Dead. A single character means a real character.
|
||||
if (event.key.length !== 1) return false;
|
||||
|
||||
// Space has to keep scrolling the page.
|
||||
if (event.key === " ") return false;
|
||||
|
||||
if (event.metaKey) return false;
|
||||
|
||||
// AltGraph is the only trustworthy signal that a modified keystroke produced
|
||||
// text rather than invoking a shortcut, and it is set on both Windows AltGr
|
||||
// and macOS Option when the layout emits an alternate character. Inferring it
|
||||
// from ctrl+alt instead reads correctly on Windows but is backwards on macOS,
|
||||
// where Option alone types accented characters and ctrl+alt is a shortcut
|
||||
// prefix (VoiceOver's, among others). Browsers that do not report it fall
|
||||
// through and decline, which costs the feature rather than stealing a key.
|
||||
if (event.getModifierState?.("AltGraph")) return true;
|
||||
|
||||
if (event.ctrlKey || event.altKey) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Is this search box actually available to the user right now? */
|
||||
export function isSearchBoxTypeable(input: TypeToSearchTarget, doc: TypeToSearchDocument): boolean {
|
||||
// jsdom implements neither layout nor elementFromPoint. With no real
|
||||
// measurement we cannot tell whether the box is on screen, and the safe
|
||||
// answer to not knowing is to leave the keystroke alone. Every browser has
|
||||
// had this API for over a decade, so no real behavior hides behind it.
|
||||
if (typeof doc.elementFromPoint !== "function") return false;
|
||||
|
||||
// Only act when nothing else holds focus, which keeps native text editing and
|
||||
// keyboard navigation intact. Anything the user tabbed to, and any focused
|
||||
// input, textarea or contenteditable, is the activeElement, so this one check
|
||||
// replaces a separate "is the target editable" test.
|
||||
if (doc.activeElement !== doc.body && doc.activeElement !== null) return false;
|
||||
|
||||
const rect = input.getBoundingClientRect();
|
||||
// A hidden element measures zero, and so does everything in a DOM without
|
||||
// layout.
|
||||
if (rect.width === 0 || rect.height === 0) return false;
|
||||
|
||||
// One hit test covers visibility and obstruction together. Off-screen returns
|
||||
// null, and a modal backdrop returns the backdrop, so there is no dependency
|
||||
// on anyone's aria markup.
|
||||
const hit = doc.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
||||
if (!hit) return false;
|
||||
|
||||
// contains() includes the node itself, so this also covers "the hit is the
|
||||
// input". An ancestor hit is deliberately rejected: that is what
|
||||
// elementFromPoint returns when the input is laid out but not hit-testable
|
||||
// (visibility:hidden, or an inert wrapper), and focus() is refused in exactly
|
||||
// those cases, so accepting it would pour keystrokes into an unreachable box.
|
||||
return input.contains(hit);
|
||||
}
|
||||
Reference in New Issue
Block a user