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>
+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") {
@@ -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);
}
+61
View File
@@ -50,6 +50,67 @@ test.describe("Landing Homepage", () => {
await expect(page.getByPlaceholder("Search tools")).toBeVisible();
});
test("typing with nothing focused fills the hero search", async ({ page }) => {
const search = page.getByPlaceholder("Search tools");
await expect(search).toBeVisible();
await page.keyboard.type("pdf");
await expect(search).toHaveValue("pdf");
await expect(search).toBeFocused();
await expect(page.locator("#hero-search-results")).toBeVisible();
});
test("pressing space scrolls the page instead of starting a search", async ({ page }) => {
const search = page.getByPlaceholder("Search tools");
await expect(search).toBeVisible();
await page.keyboard.press("Space");
await expect(search).toHaveValue("");
// Assert the behaviour the rule exists for, not just its side effect.
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(0);
});
test("typing after blurring appends to the existing hero query", async ({ page }) => {
// The append and caret logic is per-surface, not shared, so the web test for
// this does not cover the landing script.
const search = page.getByPlaceholder("Search tools");
await search.fill("pdf");
await search.blur();
await page.keyboard.type("x");
await expect(search).toHaveValue("pdfx");
await expect(page.locator("#hero-search-results")).toBeVisible();
});
test("typing does nothing once the hero search is scrolled out of view", async ({ page }) => {
const search = page.getByPlaceholder("Search tools");
await page.evaluate(() => {
window.scrollTo({ top: document.body.scrollHeight, behavior: "instant" });
});
// Assert the precondition rather than trusting the scroll landed, so this
// cannot pass for the wrong reason.
await expect(search).not.toBeInViewport();
await page.keyboard.type("pdf");
await expect(search).toHaveValue("");
});
test("editing inside the hero search keeps the native caret position", async ({ page }) => {
const search = page.getByPlaceholder("Search tools");
await search.click();
await page.keyboard.type("ab");
await page.keyboard.press("ArrowLeft");
await page.keyboard.type("x");
// Appending instead of honouring the caret would produce "abx".
await expect(search).toHaveValue("axb");
});
test("hero modality cards render", async ({ page }) => {
const cards = ["Image Tools", "Video Tools", "Audio Tools", "PDF & Documents", "File Tools"];
for (const card of cards) {
+15
View File
@@ -18,6 +18,21 @@ test.describe("Keyboard Shortcuts", () => {
await page.keyboard.press(`${MOD}+k`);
await expect(searchInput).toBeFocused();
// Without this, loosening the type-to-search predicate to accept Ctrl would
// both focus the box and insert "k" on Linux CI, and this test would still pass.
await expect(searchInput).toHaveValue("");
});
test("typing while the Settings dialog is open does not reach the search bar", async ({
loggedInPage: page,
}) => {
const searchInput = page.locator("[data-search-input]");
await openSettings(page);
await expect(page.locator('[role="dialog"]').first()).toBeVisible();
await page.keyboard.type("compress");
await expect(searchInput).toHaveValue("");
});
test("Cmd/Ctrl+/ navigates to tools (home) page", async ({ loggedInPage: page }) => {
+125
View File
@@ -1,5 +1,7 @@
import { expect, test } from "./helpers";
const MOD = process.platform === "darwin" ? "Meta" : "Control";
test.describe("Home Page", () => {
test("shows branding and search bar", async ({ loggedInPage: page }) => {
// The wordmark renders as a logo image, not text; the document title is
@@ -38,6 +40,129 @@ test.describe("Home Page", () => {
await expect(page.getByText("Compress").first()).toBeVisible();
});
test("typing with nothing focused fills the tool search", async ({ loggedInPage: page }) => {
const searchInput = page.locator("[data-search-input]");
// The home page is lazy-loaded, so wait for it to mount before typing.
// Without this the keystrokes land on an unmounted page and vanish.
await expect(searchInput).toBeVisible();
await page.keyboard.type("compress");
await expect(searchInput).toHaveValue("compress");
await expect(searchInput).toBeFocused();
await expect(page.getByText("Compress").first()).toBeVisible();
});
test("typing does nothing once the search bar is scrolled out of view", async ({
loggedInPage: page,
}) => {
const searchInput = page.locator("[data-search-input]");
// Waiting for mount is load-bearing, not politeness: the home page is
// lazy-loaded, and without this the element does not exist yet, so the
// precondition below passes vacuously and the whole test asserts nothing.
await expect(searchInput).toBeVisible();
// The app shell is h-dvh overflow-hidden and #main-content is the scroller,
// so the document does not scroll and mouse.wheel moves nothing at all.
await page.locator("#main-content").evaluate((el) => {
el.scrollTop = el.scrollHeight;
});
await expect(searchInput).not.toBeInViewport();
await page.keyboard.type("compress");
await expect(searchInput).toHaveValue("");
});
test("typing works after a client-side navigation to home", async ({ loggedInPage: page }) => {
// RouteAnnouncer moves focus 300ms after a client-side route change, which
// would break type-to-search if that focus stuck. Every other test here
// arrives by hard load, where the announcer short-circuits.
await page.goto("/automate");
await page.keyboard.press(`${MOD}+/`);
await expect(page).toHaveURL("/");
const searchInput = page.locator("[data-search-input]");
await expect(searchInput).toBeVisible();
await page.waitForTimeout(500);
await page.keyboard.type("compress");
await expect(searchInput).toHaveValue("compress");
});
test("the ?focus=search param focuses the search bar and cleans the URL", async ({
loggedInPage: page,
}) => {
// Reachable via Mod+K from a page with no search box. Untested before this,
// and type-to-search would now mask its failure by filling the box anyway.
await page.goto("/?focus=search");
await expect(page.locator("[data-search-input]")).toBeFocused();
await expect(page).toHaveURL("/");
});
test("type-to-search does not leak onto pages with their own search", async ({
loggedInPage: page,
}) => {
// The hook's scope claim: it is mounted next to the home search bar, so no
// other page gets it. Automate has its own tool-palette search that must
// stay untouched.
await page.goto("/automate");
await expect(page).toHaveURL("/automate");
await page.waitForTimeout(500);
await page.keyboard.type("abc");
const anyInputTook = await page.evaluate(() =>
Array.from(document.querySelectorAll("input")).some((i) => i.value.includes("abc")),
);
expect(anyInputTook).toBe(false);
});
test("typing does not hijack when a control already holds focus", async ({
loggedInPage: page,
}) => {
const searchInput = page.locator("[data-search-input]");
await page
.getByRole("button", { name: /^Image/ })
.first()
.focus();
await page.keyboard.type("compress");
await expect(searchInput).toHaveValue("");
});
test("typing after blurring appends and leaves the caret at the end", async ({
loggedInPage: page,
}) => {
const searchInput = page.locator("[data-search-input]");
await searchInput.fill("pdf");
await searchInput.blur();
await page.keyboard.type("x");
await expect(searchInput).toHaveValue("pdfx");
// A caret stranded at position 0 would put the next character in front.
await expect
.poll(() => searchInput.evaluate((el: HTMLInputElement) => el.selectionStart))
.toBe(4);
});
test("editing inside the tool search keeps the native caret position", async ({
loggedInPage: page,
}) => {
const searchInput = page.locator("[data-search-input]");
await searchInput.click();
await page.keyboard.type("ab");
await page.keyboard.press("ArrowLeft");
await page.keyboard.type("x");
// Appending instead of honouring the caret would produce "abx".
await expect(searchInput).toHaveValue("axb");
});
test("clicking a tool card navigates to tool page", async ({ loggedInPage: page }) => {
// Find and click a tool link (Resize is in Image > Essentials)
await page.locator("a").filter({ hasText: "Resize" }).first().click();
+241
View File
@@ -0,0 +1,241 @@
import { describe, expect, it } from "vitest";
import {
isSearchBoxTypeable,
isTypeToSearchKey,
type TypeToSearchKeyEvent,
} from "../../../packages/shared/src/search/type-to-search.js";
function ev(overrides: Partial<TypeToSearchKeyEvent> & { key: string }): TypeToSearchKeyEvent {
return {
ctrlKey: false,
metaKey: false,
altKey: false,
isComposing: false,
defaultPrevented: false,
...overrides,
};
}
/** A keystroke where the layout reports AltGraph, as Windows AltGr and macOS Option do. */
function altGraph(
overrides: Partial<TypeToSearchKeyEvent> & { key: string },
): TypeToSearchKeyEvent {
return ev({ getModifierState: (k) => k === "AltGraph", ...overrides });
}
describe("isTypeToSearchKey", () => {
it("accepts a plain letter", () => {
expect(isTypeToSearchKey(ev({ key: "c" }))).toBe(true);
});
it("accepts a digit", () => {
expect(isTypeToSearchKey(ev({ key: "5" }))).toBe(true);
});
it("accepts punctuation", () => {
expect(isTypeToSearchKey(ev({ key: "-" }))).toBe(true);
});
it("accepts a shifted capital", () => {
expect(isTypeToSearchKey(ev({ key: "A", shiftKey: true }))).toBe(true);
});
// key.length is the only gate deciding what counts as a character, in a
// product with 21 locales. Tightening it to something like /^[a-z0-9]$/i would
// silently kill most of them, and every other test here is Latin.
it.each(["ก", "क", "ا", "字", "я", "ü", "ñ", "ą", "İ"])(
"accepts the non-Latin character %s",
(key) => {
expect(isTypeToSearchKey(ev({ key }))).toBe(true);
},
);
it("accepts an AltGr character", () => {
expect(isTypeToSearchKey(altGraph({ key: "ą", ctrlKey: true, altKey: true }))).toBe(true);
});
// Uppercase AltGr characters exist in pl, tr and de. A plausible-looking
// "&& !shiftKey" tightening would break them with no other test failing.
it("accepts a capital AltGr character", () => {
expect(
isTypeToSearchKey(altGraph({ key: "Ą", ctrlKey: true, altKey: true, shiftKey: true })),
).toBe(true);
});
// macOS has no AltGr: Option alone emits the alternate character, and it
// reports AltGraph when it does.
it("accepts a macOS Option character", () => {
expect(isTypeToSearchKey(altGraph({ key: "å", altKey: true }))).toBe(true);
});
// The mirror of the case above, and the reason ctrl+alt is not treated as
// AltGr by inference: on macOS ctrl+alt is a shortcut prefix, VoiceOver's
// included, and no character is produced.
it("rejects ctrl+alt when the layout does not report AltGraph", () => {
expect(isTypeToSearchKey(ev({ key: "a", ctrlKey: true, altKey: true }))).toBe(false);
});
it("rejects ctrl+alt when the browser cannot report modifier state at all", () => {
const event = ev({ key: "a", ctrlKey: true, altKey: true });
delete (event as { getModifierState?: unknown }).getModifierState;
expect(isTypeToSearchKey(event)).toBe(false);
});
// Ctrl+Alt+1..8 are registered app shortcuts on Windows and Linux, where mod
// is Ctrl. The predicate deliberately does not arbitrate: use-keyboard-shortcuts
// runs in capture phase and calls preventDefault plus stopPropagation, so it
// never reaches this handler.
it("rejects ctrl+alt+digit, leaving shortcut arbitration to the shortcut hook", () => {
expect(isTypeToSearchKey(ev({ key: "1", ctrlKey: true, altKey: true }))).toBe(false);
});
it("rejects space so the page keeps scrolling", () => {
expect(isTypeToSearchKey(ev({ key: " " }))).toBe(false);
});
it.each(["Enter", "Tab", "Escape", "ArrowDown", "Backspace", "F1", "Dead", "Shift"])(
"rejects the non-printable key %s",
(key) => {
expect(isTypeToSearchKey(ev({ key }))).toBe(false);
},
);
it("rejects a keystroke mid-IME-composition", () => {
expect(isTypeToSearchKey(ev({ key: "n", isComposing: true }))).toBe(false);
});
it("rejects a keystroke something upstream already handled", () => {
expect(isTypeToSearchKey(ev({ key: "n", defaultPrevented: true }))).toBe(false);
});
it("rejects a Meta combination", () => {
expect(isTypeToSearchKey(ev({ key: "k", metaKey: true }))).toBe(false);
});
it("rejects Meta even when the layout reports AltGraph", () => {
expect(isTypeToSearchKey(altGraph({ key: "k", metaKey: true }))).toBe(false);
});
it("rejects Ctrl alone", () => {
expect(isTypeToSearchKey(ev({ key: "a", ctrlKey: true }))).toBe(false);
});
it("rejects Alt alone", () => {
expect(isTypeToSearchKey(ev({ key: "a", altKey: true }))).toBe(false);
});
});
describe("isSearchBoxTypeable", () => {
const rect = { left: 100, top: 40, width: 300, height: 40 };
const centre = [250, 60];
/**
* Mimics a real element: contains() is inclusive of the node itself, which is
* what makes the "hit is the input" case work without a separate identity
* check in the implementation.
*/
function box(descendants: unknown[] = [], overrides: { width?: number; height?: number } = {}) {
const self = {
getBoundingClientRect: () => ({ ...rect, ...overrides }),
contains: (other: unknown) => other === self || descendants.includes(other),
};
return self;
}
function doc(
overrides: Partial<{
body: unknown;
activeElement: unknown;
elementFromPoint?: (x: number, y: number) => unknown;
}> = {},
) {
const body = { tag: "body" };
return {
body,
activeElement: body,
elementFromPoint: () => null,
...overrides,
};
}
it("returns false when the environment has no elementFromPoint", () => {
const input = box();
const d = doc();
// jsdom does not implement elementFromPoint at all. Without this guard any
// future jsdom test that mounts the search bar dies on a TypeError.
delete (d as { elementFromPoint?: unknown }).elementFromPoint;
expect(isSearchBoxTypeable(input, d)).toBe(false);
});
it("accepts when the hit element is the input itself", () => {
const input = box();
const d = doc({
elementFromPoint: (x, y) => (x === centre[0] && y === centre[1] ? input : null),
});
expect(isSearchBoxTypeable(input, d)).toBe(true);
});
it("accepts when the hit element is inside the input", () => {
const child = { tag: "child" };
const input = box([child]);
const d = doc({ elementFromPoint: () => child });
expect(isSearchBoxTypeable(input, d)).toBe(true);
});
// This is what elementFromPoint returns when the input is laid out but not
// hit-testable (visibility:hidden, or an inert wrapper). focus() is refused
// there, so accepting it would swallow a whole query into an unreachable box.
it("rejects when the hit element is an ancestor of the input", () => {
const input = box();
const wrapper = { contains: (other: unknown) => other === input };
const d = doc({ elementFromPoint: () => wrapper });
expect(isSearchBoxTypeable(input, d)).toBe(false);
});
it("rejects when an unrelated overlay covers the input", () => {
const overlay = { contains: () => false };
const input = box();
const d = doc({ elementFromPoint: () => overlay });
expect(isSearchBoxTypeable(input, d)).toBe(false);
});
it("rejects when the input is scrolled out of the viewport", () => {
const input = box();
const d = doc({ elementFromPoint: () => null });
expect(isSearchBoxTypeable(input, d)).toBe(false);
});
it("rejects when something else already holds focus", () => {
const input = box();
const d = doc({ activeElement: { tag: "button" }, elementFromPoint: () => input });
expect(isSearchBoxTypeable(input, d)).toBe(false);
});
it("accepts when nothing at all holds focus", () => {
const input = box();
const d = doc({ activeElement: null, elementFromPoint: () => input });
expect(isSearchBoxTypeable(input, d)).toBe(true);
});
it("rejects a zero-width box, which is how a hidden element measures", () => {
const input = box([], { width: 0 });
const d = doc({ elementFromPoint: () => input });
expect(isSearchBoxTypeable(input, d)).toBe(false);
});
it("rejects a zero-height box", () => {
const input = box([], { height: 0 });
const d = doc({ elementFromPoint: () => input });
expect(isSearchBoxTypeable(input, d)).toBe(false);
});
});