This is an automated email from the ASF dual-hosted git repository.
choo121600 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new ecb95734544 UI: Fix select filter pills staying open in Safari (#76013)
ecb95734544 is described below
commit ecb957345445feba9265310d60fac3b1b0106e98
Author: Yuseok Jo <[email protected]>
AuthorDate: Fri Oct 30 23:55:28 2026 +0900
UI: Fix select filter pills staying open in Safari (#76013)
---
.../ui/src/components/FilterBar/FilterBar.test.tsx | 20 ++++++++++++++++
.../components/FilterBar/filters/LoopFilter.tsx | 17 +++++---------
.../FilterBar/filters/RunStateFilter.tsx | 27 ++++++++++------------
.../components/FilterBar/filters/SelectFilter.tsx | 21 ++++++++---------
.../src/airflow/ui/tests/e2e/pages/DagsPage.ts | 4 ++--
5 files changed, 49 insertions(+), 40 deletions(-)
diff --git
a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.test.tsx
b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.test.tsx
index 0e865dc450d..8f880f64337 100644
--- a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.test.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.test.tsx
@@ -145,6 +145,26 @@ describe("FilterBar select filters", () => {
expect(valueText).not.toBeNull();
expect(globalThis.getComputedStyle(valueText as
HTMLElement).overflow).toBe("visible");
});
+
+ it("collapses the pill after a selection even when focus stays inside it",
async () => {
+ // WebKit hands focus back to the select trigger, so blurring the active
element leaves the pill open.
+ const blur = vi.spyOn(HTMLElement.prototype, "blur").mockImplementation(()
=> undefined);
+ const selectConfig: FilterConfig = {
+ key: "state",
+ label: "State",
+ options: [{ label: "Failed", value: "failed" }],
+ type: "select",
+ };
+
+ render(<FilterBar configs={[selectConfig]} onFiltersChange={vi.fn()} />, {
wrapper });
+
+ fireEvent.click(screen.getByTestId("add-filter-button"));
+ fireEvent.click(await screen.findByTestId("add-filter-state"));
+ fireEvent.click(await screen.findByTestId("state-filter-failed"));
+
+ await waitFor(() =>
expect(screen.getByTestId("state-pill")).toBeInTheDocument());
+ blur.mockRestore();
+ });
});
describe("FilterBar multiselect filters", () => {
diff --git
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/LoopFilter.tsx
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/LoopFilter.tsx
index 0e45a6a9aa2..a48473c0c9e 100644
---
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/LoopFilter.tsx
+++
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/LoopFilter.tsx
@@ -37,14 +37,6 @@ import { FilterPill } from "../FilterPill";
import type { FilterPluginProps } from "../types";
import { isLoopFilterValue } from "./loopParams";
-const collapsePill = () => {
- setTimeout(() => {
- const activeElement = document.activeElement as HTMLElement;
-
- activeElement.blur();
- }, 0);
-};
-
/**
* One pill, one list: every loop in the run and the passes it ran, as single
choices.
*
@@ -80,7 +72,6 @@ export const LoopFilter = ({ filter, onChange, onRemove }:
FilterPluginProps) =>
}
hasJustSelected.current = true;
onChange(decodeLoopOption(choice));
- collapsePill();
};
return (
@@ -130,8 +121,12 @@ export const LoopFilter = ({ filter, onChange, onRemove }:
FilterPluginProps) =>
flex="1"
h="full"
onOpenChange={({ open }) => {
- if (!open && !hasJustSelected.current) {
- onRequestClose();
+ if (!open) {
+ if (hasJustSelected.current) {
+ setTimeout(onRequestClose, 0);
+ } else {
+ onRequestClose();
+ }
}
hasJustSelected.current = false;
}}
diff --git
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/RunStateFilter.tsx
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/RunStateFilter.tsx
index bd99830d15b..3e9a59c7c34 100644
---
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/RunStateFilter.tsx
+++
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/RunStateFilter.tsx
@@ -45,14 +45,6 @@ type RunStateFilterConfig = {
options: Array<SelectOption>;
};
-const collapsePill = () => {
- setTimeout(() => {
- const activeElement = document.activeElement as HTMLElement;
-
- activeElement.blur();
- }, 0);
-};
-
/**
* One pill filtering Dags on a run state matched within a lookback: the
latest run only, a
* recent time window, or the whole run history. Committed as a composite
value that the
@@ -79,7 +71,6 @@ export const RunStateFilter = ({ filter, onChange, onRemove
}: FilterPluginProps
}
hasJustSelected.current = true;
onChange({ lookback: value?.lookback ?? pendingLookback, state: newState
});
- collapsePill();
};
const handleLookbackChange = ({ value: selected }: { value: Array<string> })
=> {
@@ -92,7 +83,6 @@ export const RunStateFilter = ({ filter, onChange, onRemove
}: FilterPluginProps
setPendingLookback(newLookback);
if (value !== undefined) {
onChange({ lookback: newLookback, state: value.state });
- collapsePill();
}
};
@@ -164,12 +154,16 @@ export const RunStateFilter = ({ filter, onChange,
onRemove }: FilterPluginProps
defaultOpen={value === undefined}
flex="1"
h="full"
- // Dismissing the menu hands focus back to the trigger inside the
pill, so no blur
- // fires and the pill would stay in edit mode. A close that
follows a selection is
- // left alone: the blur that selection triggers collapses the pill
instead.
+ // Closing the menu hands focus back to the trigger inside the
pill, so no blur fires
+ // and the pill would stay in edit mode. A close that follows a
selection waits a tick,
+ // so the pill sees the chosen value instead of dropping the
filter as abandoned.
onOpenChange={({ open }) => {
- if (!open && !hasJustSelected.current) {
- onRequestClose();
+ if (!open) {
+ if (hasJustSelected.current) {
+ setTimeout(onRequestClose, 0);
+ } else {
+ onRequestClose();
+ }
}
hasJustSelected.current = false;
}}
@@ -199,6 +193,9 @@ export const RunStateFilter = ({ filter, onChange, onRemove
}: FilterPluginProps
// Unlike the state select, closing this one without picking
anything is not an
// abandoned filter: the state select is the pill's primary
control.
if (!open) {
+ if (hasJustSelected.current && value !== undefined) {
+ setTimeout(onRequestClose, 0);
+ }
hasJustSelected.current = false;
}
}}
diff --git
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/SelectFilter.tsx
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/SelectFilter.tsx
index e1b50eaa0c4..877166d6c6c 100644
---
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/SelectFilter.tsx
+++
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/SelectFilter.tsx
@@ -46,13 +46,6 @@ export const SelectFilter = ({ filter, onChange, onRemove }:
FilterPluginProps)
hasJustSelected.current = true;
onChange(newValue);
-
- // Trigger blur to close the editing mode after selection
- setTimeout(() => {
- const activeElement = document.activeElement as HTMLElement;
-
- activeElement.blur();
- }, 0);
};
const hasValue = filter.value !== null && filter.value !== undefined &&
filter.value !== "";
@@ -107,12 +100,16 @@ export const SelectFilter = ({ filter, onChange, onRemove
}: FilterPluginProps)
// open straight onto the options instead of making the user click
again.
defaultOpen={!hasValue}
h="full"
- // Dismissing the menu hands focus back to the trigger inside the
pill, so no blur
- // fires and the pill would stay in edit mode. A close that
follows a selection is
- // left alone: the blur that selection triggers collapses the pill
instead.
+ // Closing the menu hands focus back to the trigger inside the
pill, so no blur fires
+ // and the pill would stay in edit mode. A close that follows a
selection waits a tick,
+ // so the pill sees the chosen value instead of dropping the
filter as abandoned.
onOpenChange={({ open }) => {
- if (!open && !hasJustSelected.current) {
- onRequestClose();
+ if (!open) {
+ if (hasJustSelected.current) {
+ setTimeout(onRequestClose, 0);
+ } else {
+ onRequestClose();
+ }
}
hasJustSelected.current = false;
}}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts
index cc8dc68096b..97fff31b87c 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts
@@ -126,8 +126,8 @@ export class DagsPage extends BasePage {
await this.page.getByTestId("add-filter-run_state").click();
}
await this.page.getByTestId(`run_state-filter-${status}`).click();
- // Selecting blurs the pill, which collapses it ~150ms later. Hand back
a settled bar
- // instead of one mid-transition.
+ // Selecting collapses the pill a tick later. Hand back a settled bar
instead of one
+ // mid-transition.
await expect(this.runStatePill).toBeVisible({ timeout: 5000 });
}
await responsePromise;