This is an automated email from the ASF dual-hosted git repository.
bbovenzi 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 f014690eb38 UI: Translate the aria-labels on icon-only buttons (#75965)
f014690eb38 is described below
commit f014690eb38fa23d41e7d4b7b4ad3838b06af67b
Author: Pushkal Gupta <[email protected]>
AuthorDate: Sat Oct 31 02:35:45 2026 +0530
UI: Translate the aria-labels on icon-only buttons (#75965)
---
.../airflow/ui/public/i18n/locales/en/common.json | 7 ++-
.../src/airflow/ui/public/i18n/locales/en/dag.json | 4 +-
.../ui/src/components/AdvancedSearchToggle.tsx | 4 +-
.../ui/src/components/DataTable/DataTable.tsx | 4 +-
.../FilterBar/filters/MultiSelectPill.tsx | 4 +-
.../FilterBar/filters/TagsFilter.test.tsx | 2 +-
.../FilterBar/filters/TextSearchFilter.tsx | 4 +-
.../ui/src/components/PresetFiltersMenu.test.tsx | 6 +--
.../ui/src/components/PresetFiltersMenu.tsx | 2 +-
.../src/airflow/ui/src/i18n/ariaLabels.test.ts | 50 ++++++++++++++++++++++
.../TaskInstance/Logs/LogSearchInput.test.tsx | 16 +++----
.../src/pages/TaskInstance/Logs/LogSearchInput.tsx | 4 +-
.../ui/src/pages/TaskInstance/Logs/Logs.test.tsx | 22 +++++-----
13 files changed, 95 insertions(+), 34 deletions(-)
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index e938db6b4c7..3d00331b2a6 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -221,6 +221,10 @@
"write": "Write"
},
"overallStatus": "Overall Status",
+ "pagination": {
+ "nextPage": "Next page",
+ "previousPage": "Previous page"
+ },
"partition_one": "Partition",
"partition_other": "Partitions",
"partitionedDagRun_one": "Partitioned Dag Run",
@@ -267,7 +271,8 @@
"search": {
"advanced": {
"description": "Match anywhere in the value (substring search). Slower
on large deployments because it can't use the default B-tree index. See the
docs section on custom metadata indexes for details.",
- "title": "Match anywhere"
+ "title": "Match anywhere",
+ "toggleLabel": "Toggle match-anywhere search"
}
},
"security": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
index c7f0c598906..cb02e29d5ca 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
@@ -113,8 +113,10 @@
"noTryNumber": "No try number",
"search": {
"matchCount": "{{current, number}} of {{total, number}}",
+ "nextMatch": "Next match",
"noMatches": "No matches",
- "placeholder": "Search logs..."
+ "placeholder": "Search logs...",
+ "previousMatch": "Previous match"
},
"settings": "Log Settings",
"tryNotStarted": "This try hasn't started yet.",
diff --git
a/airflow-core/src/airflow/ui/src/components/AdvancedSearchToggle.tsx
b/airflow-core/src/airflow/ui/src/components/AdvancedSearchToggle.tsx
index bded2026233..488abd41814 100644
--- a/airflow-core/src/airflow/ui/src/components/AdvancedSearchToggle.tsx
+++ b/airflow-core/src/airflow/ui/src/components/AdvancedSearchToggle.tsx
@@ -59,7 +59,7 @@ export const AdvancedSearchToggle = ({
<Box
alignItems="center"
alignSelf="stretch"
- aria-label="Toggle match-anywhere search"
+ aria-label={translate("search.advanced.toggleLabel")}
aria-pressed={enabled}
as="button"
bg={enabled ? "colorPalette.solid" : "gray.muted"}
@@ -78,7 +78,7 @@ export const AdvancedSearchToggle = ({
</Box>
) : (
<IconButton
- aria-label="Toggle match-anywhere search"
+ aria-label={translate("search.advanced.toggleLabel")}
aria-pressed={enabled}
data-testid="advanced-search-toggle"
flexShrink={0}
diff --git a/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx
b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx
index 2cdfdf4da4e..79fc784cecf 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx
@@ -368,7 +368,7 @@ export const DataTable = <TData extends RowData>({
{hasCursorPagination && initialState ? (
<HStack my={2}>
<IconButton
- aria-label="Previous page"
+ aria-label={translate("pagination.previousPage")}
data-testid="prev"
disabled={!hasPrevious}
onClick={() => {
@@ -380,7 +380,7 @@ export const DataTable = <TData extends RowData>({
<HiChevronLeft />
</IconButton>
<IconButton
- aria-label="Next page"
+ aria-label={translate("pagination.nextPage")}
data-testid="next"
disabled={!hasNext}
onClick={() => {
diff --git
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/MultiSelectPill.tsx
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/MultiSelectPill.tsx
index ce93091f3b8..a5666bd69d0 100644
---
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/MultiSelectPill.tsx
+++
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/MultiSelectPill.tsx
@@ -20,6 +20,7 @@ import { useEffect, useRef } from "react";
import { Box, HStack, Text } from "@chakra-ui/react";
import { CreatableSelect, Select as ReactSelect, type SelectInstance } from
"chakra-react-select";
+import { useTranslation } from "react-i18next";
import { LuRegex } from "react-icons/lu";
import { AdvancedSearchToggle } from "src/components/AdvancedSearchToggle";
@@ -62,6 +63,7 @@ export const MultiSelectPill = ({
onRemove,
options,
}: Props) => {
+ const { t: translate } = useTranslation("common");
const { mode, setMode } = useMatchMode(filter.config.matchModeKey);
const advanced = useAdvancedSearch(filter.config.key);
const values = Array.isArray(filter.value) ? filter.value : [];
@@ -86,7 +88,7 @@ export const MultiSelectPill = ({
// Each value is its own node so the collapsed chip stays queryable by
value.
displayValue={
<HStack display="inline-flex" gap={1}>
- {isAdvanced ? <LuRegex aria-label="match anywhere" /> : undefined}
+ {isAdvanced ? <LuRegex
aria-label={translate("search.advanced.title")} /> : undefined}
{mode === "all" && values.length >= 2 ? (
<Box as="span" color="fg.muted" fontSize="xs">
{`(${mode})`}
diff --git
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TagsFilter.test.tsx
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TagsFilter.test.tsx
index f01de5bfd27..e5cc8ed2997 100644
---
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TagsFilter.test.tsx
+++
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TagsFilter.test.tsx
@@ -122,7 +122,7 @@ describe("TagsFilter advanced search", () => {
const pill = screen.getByTestId("tags-pill");
- expect(within(pill).getByLabelText("match anywhere")).toBeInTheDocument();
+
expect(within(pill).getByLabelText("search.advanced.title")).toBeInTheDocument();
});
it("does not render the toggle without supportsAdvancedSearch", async () => {
diff --git
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TextSearchFilter.tsx
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TextSearchFilter.tsx
index b0754174385..10d5d3fec4b 100644
---
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TextSearchFilter.tsx
+++
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TextSearchFilter.tsx
@@ -19,6 +19,7 @@
import { useRef, type ChangeEvent } from "react";
import { HStack } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
import { LuRegex } from "react-icons/lu";
import { InputWithAddon } from "src/system-components";
@@ -34,6 +35,7 @@ import type { FilterPluginProps } from "../types";
import { isValidFilterValue } from "../utils";
export const TextSearchFilter = ({ filter, onChange, onRemove }:
FilterPluginProps) => {
+ const { t: translate } = useTranslation("common");
const hotkeyInputRef = useRef<HTMLInputElement>(null);
const advanced = useAdvancedSearch(filter.config.key);
const showAdvancedToggle = filter.config.supportsAdvancedSearch === true;
@@ -64,7 +66,7 @@ export const TextSearchFilter = ({ filter, onChange, onRemove
}: FilterPluginPro
displayValue={
isAdvanced && stringValue !== "" ? (
<HStack as="span" gap={1}>
- <LuRegex aria-label="match anywhere" />
+ <LuRegex aria-label={translate("search.advanced.title")} />
{stringValue}
</HStack>
) : (
diff --git
a/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.test.tsx
b/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.test.tsx
index 464aace33e8..16164c26e9c 100644
--- a/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.test.tsx
+++ b/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.test.tsx
@@ -151,7 +151,7 @@ describe("PresetFiltersMenu", () => {
render(<PresetFiltersMenu />, { wrapper: createWrapper(["/dags"]) });
await openMenu();
- fireEvent.click(screen.getAllByLabelText("Delete preset filter")[0] as
HTMLElement);
+ fireEvent.click(screen.getAllByLabelText("presetFilters.deleteTitle")[0]
as HTMLElement);
fireEvent.click(await screen.findByTestId("delete-confirm-button"));
await waitFor(() => {
@@ -170,7 +170,7 @@ describe("PresetFiltersMenu", () => {
render(<PresetFiltersMenu />, { wrapper: createWrapper(["/dags"]) });
await openMenu();
- fireEvent.click(screen.getByLabelText("Delete preset filter"));
+ fireEvent.click(screen.getByLabelText("presetFilters.deleteTitle"));
fireEvent.click(await screen.findByTestId("delete-cancel-button"));
await waitFor(() => {
@@ -274,7 +274,7 @@ describe("PresetFiltersMenu", () => {
render(<PresetFiltersMenu />, { wrapper:
createWrapper(["/dags?state=running"]) });
await openMenu();
- fireEvent.click(screen.getByLabelText("Delete preset filter"));
+ fireEvent.click(screen.getByLabelText("presetFilters.deleteTitle"));
fireEvent.click(await screen.findByTestId("delete-confirm-button"));
await waitFor(() => {
diff --git a/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.tsx
b/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.tsx
index 8c0555dd959..44e606fcf0f 100644
--- a/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.tsx
+++ b/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.tsx
@@ -240,7 +240,7 @@ export const PresetFiltersMenu = () => {
{defaultPresetName === preset.name ? <MdPushPin /> :
<MdOutlinePushPin />}
</IconButton>
<IconButton
- aria-label="Delete preset filter"
+ aria-label={translate("presetFilters.deleteTitle")}
onClick={() => setPresetToDelete(preset.name)}
size="sm"
variant="ghost"
diff --git a/airflow-core/src/airflow/ui/src/i18n/ariaLabels.test.ts
b/airflow-core/src/airflow/ui/src/i18n/ariaLabels.test.ts
new file mode 100644
index 00000000000..cecaec1c202
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/i18n/ariaLabels.test.ts
@@ -0,0 +1,50 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { describe, expect, it } from "vitest";
+
+// `i18next/no-literal-string` runs in its default `jsx-text-only` mode, which
checks JSX text and
+// no attributes, so an aria-label is the one piece of user-facing copy lint
cannot see. Widening
+// the rule to attributes reports 27 strings that are fine, mostly translation
keys inside the
+// `translate(...)` call it is meant to encourage, so the narrow check lives
here instead.
+const sourceFiles: Record<string, string> = import.meta.glob("../**/*.tsx", {
+ eager: true,
+ import: "default",
+ query: "?raw",
+});
+
+// Only a fully quoted value, with or without braces: aria-label="Next page" or
+// aria-label={"Next page"}. A template literal that interpolates a value
needs a key with
+// its own interpolation, so those are left for a follow-up rather than
exempted here.
+const LITERAL_ARIA_LABEL = /aria-label=\{?"(?<label>[^"]*)"/gu;
+const SOURCE_PREFIX = /^\.\.\//u;
+
+const findLiteralAriaLabels = (file: string, source: string): Array<string> =>
+ [...source.matchAll(LITERAL_ARIA_LABEL)].map(
+ (match) => `${file.replace(SOURCE_PREFIX, "")}:
aria-label="${match.groups?.label ?? ""}"`,
+ );
+
+describe("aria-label values", () => {
+ it("never hardcodes a string an icon-only control is named by", () => {
+ const hardcoded = Object.entries(sourceFiles)
+ .filter(([file]) => !file.includes(".test."))
+ .flatMap(([file, source]) => findLiteralAriaLabels(file, source));
+
+ expect(hardcoded).toEqual([]);
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.test.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.test.tsx
index 812743341af..ac0be3cc7b9 100644
---
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.test.tsx
+++
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.test.tsx
@@ -42,16 +42,16 @@ describe("LogSearchInput", () => {
it("does not show match counter or navigation buttons when searchQuery is
empty", () => {
render(<LogSearchInput {...defaultProps} />, { wrapper: Wrapper });
- expect(screen.queryByRole("button", { name: /next match/iu
})).toBeNull();
- expect(screen.queryByRole("button", { name: /previous match/iu
})).toBeNull();
+ expect(screen.queryByRole("button", { name: /nextMatch/u })).toBeNull();
+ expect(screen.queryByRole("button", { name: /previousMatch/u
})).toBeNull();
});
it("shows match counter and navigation buttons when searchQuery is
non-empty", () => {
render(<LogSearchInput {...defaultProps} searchQuery="error"
totalMatches={3} />, {
wrapper: Wrapper,
});
- expect(screen.getByRole("button", { name: /next match/iu
})).toBeInTheDocument();
- expect(screen.getByRole("button", { name: /previous match/iu
})).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /nextMatch/u
})).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /previousMatch/u
})).toBeInTheDocument();
});
});
@@ -118,7 +118,7 @@ describe("LogSearchInput", () => {
},
);
- fireEvent.click(screen.getByRole("button", { name: /next match/iu }));
+ fireEvent.click(screen.getByRole("button", { name: /nextMatch/u }));
expect(onSearchNext).toHaveBeenCalledTimes(1);
});
@@ -137,7 +137,7 @@ describe("LogSearchInput", () => {
},
);
- fireEvent.click(screen.getByRole("button", { name: /previous match/iu
}));
+ fireEvent.click(screen.getByRole("button", { name: /previousMatch/u }));
expect(onSearchPrevious).toHaveBeenCalledTimes(1);
});
@@ -146,8 +146,8 @@ describe("LogSearchInput", () => {
wrapper: Wrapper,
});
- expect(screen.getByRole("button", { name: /next match/iu
})).toBeDisabled();
- expect(screen.getByRole("button", { name: /previous match/iu
})).toBeDisabled();
+ expect(screen.getByRole("button", { name: /nextMatch/u
})).toBeDisabled();
+ expect(screen.getByRole("button", { name: /previousMatch/u
})).toBeDisabled();
});
});
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.tsx
index d3acd44888a..a04d9a82d73 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.tsx
@@ -85,7 +85,7 @@ export const LogSearchInput = ({
: translate("logs.search.noMatches")}
</Text>
<IconButton
- aria-label="Previous match"
+ aria-label={translate("logs.search.previousMatch")}
disabled={totalMatches === 0}
onClick={onSearchPrevious}
size="2xs"
@@ -93,7 +93,7 @@ export const LogSearchInput = ({
<FiChevronUp />
</IconButton>
<IconButton
- aria-label="Next match"
+ aria-label={translate("logs.search.nextMatch")}
disabled={totalMatches === 0}
onClick={onSearchNext}
size="2xs"
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
index 1ac5a031a74..d9a6f01946c 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
@@ -319,8 +319,8 @@ describe("Task log search", () => {
// Navigation buttons should become enabled once matches are found
await waitFor(() => {
- expect(screen.getByRole("button", { name: /next match/iu
})).not.toBeDisabled();
- expect(screen.getByRole("button", { name: /previous match/iu
})).not.toBeDisabled();
+ expect(screen.getByRole("button", { name: /nextMatch/u
})).not.toBeDisabled();
+ expect(screen.getByRole("button", { name: /previousMatch/u
})).not.toBeDisabled();
});
});
@@ -337,7 +337,7 @@ describe("Task log search", () => {
await waitFor(() => {
// Navigation buttons should be disabled with zero matches
- const nextBtn = screen.getByRole("button", { name: /next match/iu });
+ const nextBtn = screen.getByRole("button", { name: /nextMatch/u });
expect(nextBtn).toBeDisabled();
});
@@ -354,7 +354,7 @@ describe("Task log search", () => {
fireEvent.change(searchInput, { target: { value: "running" } });
- await waitFor(() => expect(screen.queryByRole("button", { name: /next
match/iu })).toBeInTheDocument());
+ await waitFor(() => expect(screen.queryByRole("button", { name:
/nextMatch/u })).toBeInTheDocument());
fireEvent.keyDown(searchInput, { key: "Escape" });
@@ -376,13 +376,13 @@ describe("Task log search", () => {
fireEvent.change(searchInput, { target: { value: "state" } });
// Wait for matches to be found (navigation buttons become enabled)
- await waitFor(() => expect(screen.getByRole("button", { name: /next
match/iu })).not.toBeDisabled());
+ await waitFor(() => expect(screen.getByRole("button", { name: /nextMatch/u
})).not.toBeDisabled());
// Navigate forward — buttons remain enabled
fireEvent.keyDown(searchInput, { key: "Enter" });
await waitFor(() => {
- expect(screen.getByRole("button", { name: /next match/iu
})).not.toBeDisabled();
+ expect(screen.getByRole("button", { name: /nextMatch/u
})).not.toBeDisabled();
});
});
@@ -398,13 +398,13 @@ describe("Task log search", () => {
fireEvent.change(searchInput, { target: { value: "state" } });
// Wait for matches to be found
- await waitFor(() => expect(screen.getByRole("button", { name: /previous
match/iu })).not.toBeDisabled());
+ await waitFor(() => expect(screen.getByRole("button", { name:
/previousMatch/u })).not.toBeDisabled());
// Navigate backward — buttons remain enabled
fireEvent.keyDown(searchInput, { key: "Enter", shiftKey: true });
await waitFor(() => {
- expect(screen.getByRole("button", { name: /previous match/iu
})).not.toBeDisabled();
+ expect(screen.getByRole("button", { name: /previousMatch/u
})).not.toBeDisabled();
});
});
@@ -422,13 +422,13 @@ describe("Task log search", () => {
fireEvent.change(searchInput, { target: { value: "INFO" } });
await waitFor(() => {
- expect(screen.getByRole("button", { name: /next match/iu
})).not.toBeDisabled();
+ expect(screen.getByRole("button", { name: /nextMatch/u
})).not.toBeDisabled();
});
// Verify per-line search by navigating through matches.
// With the old code (1 match per group), there were only 3 matches for
"INFO".
// Now each line is a separate match, so we can navigate through many more.
- const nextBtn = screen.getByRole("button", { name: /next match/iu });
+ const nextBtn = screen.getByRole("button", { name: /nextMatch/u });
// Navigate forward 4 times — if only 3 matches existed, the 4th click
would wrap to #1
// We just verify it stays enabled (which it always does with >0 matches).
@@ -456,7 +456,7 @@ describe("Task log search", () => {
fireEvent.change(searchInput, { target: { value: "starting attempt" } });
await waitFor(() => {
- const nextBtn = screen.getByRole("button", { name: /next match/iu });
+ const nextBtn = screen.getByRole("button", { name: /nextMatch/u });
expect(nextBtn).not.toBeDisabled();
});