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();
     });

Reply via email to