diff --git a/packages/core/src/components/data-table/toolbar.test.tsx b/packages/core/src/components/data-table/toolbar.test.tsx
index 6745a061..1ca588c7 100644
--- a/packages/core/src/components/data-table/toolbar.test.tsx
+++ b/packages/core/src/components/data-table/toolbar.test.tsx
@@ -445,6 +445,21 @@ describe("AddFilterPanel", () => {
// ---------------------------------------------------------------------------
describe("FilterChip", () => {
+ it("focuses the operator search input when the operator popover opens", async () => {
+ const user = userEvent.setup();
+ const control = makeControl({
+ filters: [{ field: "name", operator: "contains", value: "Alice" }],
+ });
+ render(, {
+ wrapper,
+ });
+
+ await user.click(screen.getByRole("button", { name: "contains" }));
+
+ const input = await screen.findByPlaceholderText("Search...");
+ expect(document.activeElement).toBe(input);
+ });
+
it("calls removeFilter when the remove button is clicked", async () => {
const user = userEvent.setup();
const control = makeControl({
diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx
index 3b74d5de..06fc311c 100644
--- a/packages/core/src/components/data-table/toolbar.tsx
+++ b/packages/core/src/components/data-table/toolbar.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import { useCallback, useMemo, useState, type ReactNode } from "react";
import { Popover } from "@base-ui/react/popover";
import { ChevronDown, Filter as FilterIcon, X, Check, Search } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -287,17 +287,22 @@ function AddFilterPanel({
if (col) setOperator(seedPanelOperator(control, col));
};
- // Keep the selection in sync with the search: if the query filters out the
- // currently-selected field, advance to the first still-visible field so the
- // field list and the value editor don't desync (empty highlight on the left
- // while the right still shows the old field's editor).
- useEffect(() => {
- if (!fq || visibleFieldColumns.length === 0) return;
- if (visibleFieldColumns.some((c) => c.filter.field === fieldName)) return;
- const first = visibleFieldColumns[0];
+ const handleFieldQueryChange = (value: string) => {
+ setFieldQuery(value);
+
+ const nextQuery = value.trim().toLowerCase();
+ if (!nextQuery) return;
+
+ const nextVisible = columns.filter((c) =>
+ (c.label ?? c.filter.field).toLowerCase().includes(nextQuery),
+ );
+ if (nextVisible.length === 0) return;
+ if (nextVisible.some((c) => c.filter.field === fieldName)) return;
+
+ const first = nextVisible[0];
setFieldName(first.filter.field);
setOperator(seedPanelOperator(control, first));
- }, [fq, visibleFieldColumns, fieldName, control]);
+ };
// Always reopen on the first field rather than wherever the user last was.
const handleOpenChange = (next: boolean) => {
@@ -358,7 +363,7 @@ function AddFilterPanel({
setFieldQuery(e.target.value)}
+ onChange={(e) => handleFieldQueryChange(e.target.value)}
placeholder={t("searchFields")}
aria-label={t("searchFields")}
className="astw:h-8 astw:pl-8 astw:text-sm"
@@ -1255,12 +1260,6 @@ function OperatorList({
}) {
const t = useDataTableT();
const [query, setQuery] = useState("");
- const ref = useRef(null);
-
- useEffect(() => {
- const id = requestAnimationFrame(() => ref.current?.focus());
- return () => cancelAnimationFrame(id);
- }, []);
const q = query.trim().toLowerCase();
const items = operators
@@ -1271,8 +1270,10 @@ function OperatorList({
+ {/* Base UI's Popover.Popup moves initial focus to the first tabbable
+ element by default, which is this input. On touch it focuses the
+ popup instead so the virtual keyboard doesn't jump open. */}
setQuery(e.target.value)}
placeholder={t("filterOperatorSearchPlaceholder")}