mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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]"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user