fix(labs): keep keyboard focus where it belongs around the search field

Clearing the search with its button removed the button under the
focus and dropped focus onto the page; it now stays in the field. The
/ shortcut no longer pulls focus out of an open dialog, menu or list,
and ignores a key another handler already took or one typed while an
input method is composing.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 00:16:39 +01:00
1 parent 74464184c5
commit a40c9e4ea4
3 files changed
+78 -5

No files matched your search

@@ -1,8 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { page } from "vitest/browser";
import { page, userEvent } from "vitest/browser";
import { useState } from "react";
import { renderWithApp } from "../../../../tests/browser/render";
import { LabFilterRail, type LabFilterRailProps } from "../lab-filter-rail";
import { useLabListFilters } from "../use-lab-list-filters";
function props(): LabFilterRailProps {
return {
@@ -156,3 +158,58 @@ describe("<LabFilterRail> layout", () => {
expect(Math.max(...centers) - Math.min(...centers)).toBeLessThan(2);
});
});
/** The rail wired to the real filter hook, with a dialog to open over it. */
function WiredRail() {
const lab = useLabListFilters();
const [dialog, setDialog] = useState(false);
return (
<>
<LabFilterRail
{...props()}
filters={lab.filters}
searchDraft={lab.searchDraft}
onSearchChange={lab.setSearchDraft}
searchInputRef={lab.searchInputRef}
/>
<button type="button" onClick={() => setDialog(true)}>
Open dialog
</button>
{dialog ? (
<div role="dialog" aria-label="Sheet">
<button type="button" autoFocus>
Inside
</button>
</div>
) : null}
</>
);
}
describe("<LabFilterRail> keyboard", () => {
it("focuses the search field on / and keeps focus there after clearing", async () => {
const { screen } = await renderWithApp(<WiredRail />);
const search = screen.getByRole("searchbox", { name: "Search biomarkers" });
(
screen.getByRole("button", { name: "Open dialog" }).element() as HTMLElement
).focus();
await userEvent.keyboard("/");
await expect.element(search).toHaveFocus();
await expect.element(search).toHaveValue("");
await userEvent.keyboard("iron");
await screen.getByRole("button", { name: "Clear search" }).click();
await expect.element(search).toHaveValue("");
await expect.element(search).toHaveFocus();
});
it("leaves / alone while focus is inside an open dialog", async () => {
const { screen } = await renderWithApp(<WiredRail />);
await screen.getByRole("button", { name: "Open dialog" }).click();
const inside = screen.getByRole("button", { name: "Inside" });
await expect.element(inside).toHaveFocus();
await userEvent.keyboard("/");
await expect.element(inside).toHaveFocus();
});
});
+5 -1
View File
@@ -97,7 +97,11 @@ export function LabFilterRail({
) : (
<button
type="button"
onClick={() => onSearchChange("")}
onClick={() => {
onSearchChange("");
// The button goes away with the text; focus stays in the field.
searchInputRef.current?.focus();
}}
aria-label={t("labs.filter.clearSearch")}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 absolute top-1/2 right-0 inline-flex size-11 -translate-y-1/2 sm:size-10 items-center justify-center rounded-full outline-none focus-visible:ring-[3px]"
>
+15 -3
View File
@@ -53,10 +53,19 @@ export function useLabListFilters() {
return () => clearTimeout(handle);
}, [searchDraft, filters.q, applyFilters]);
// "/" focuses search input anywhere on the page
// "/" focuses the search field, unless the key is meant for something
// else: a field being typed in, or an open dialog, menu or list whose
// focus must not jump to the page behind it.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) {
if (
event.key !== "/" ||
event.metaKey ||
event.ctrlKey ||
event.altKey ||
event.defaultPrevented ||
event.isComposing
) {
return;
}
const target = event.target as HTMLElement | null;
@@ -65,7 +74,10 @@ export function useLabListFilters() {
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable)
target.isContentEditable ||
target.closest(
'[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"]',
))
) {
return;
}