diff --git a/changelog.d/fixes/11599-console-log-controls-accessibility.md b/changelog.d/fixes/11599-console-log-controls-accessibility.md new file mode 100644 index 0000000000..a2137c2e44 --- /dev/null +++ b/changelog.d/fixes/11599-console-log-controls-accessibility.md @@ -0,0 +1 @@ +- **fix(ui):** Console log Refresh and Copy controls now expose localized accessible names, keep copy actions visible on keyboard focus, and announce copy completion safely ([#11599](https://github.com/diegosouzapw/OmniRoute/pull/11599)) — thanks @pacocartones diff --git a/src/shared/components/ConsoleLogViewer.tsx b/src/shared/components/ConsoleLogViewer.tsx index 9ddcbb347e..d4332a5417 100644 --- a/src/shared/components/ConsoleLogViewer.tsx +++ b/src/shared/components/ConsoleLogViewer.tsx @@ -48,6 +48,7 @@ export default function ConsoleLogViewer() { const locale = useLocale(); const t = useTranslations("loggers"); const tv = useTranslations("logs.consoleViewer"); + const tc = useTranslations("common"); const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -57,6 +58,7 @@ export default function ConsoleLogViewer() { const [lastUpdated, setLastUpdated] = useState(null); const [copiedIdx, setCopiedIdx] = useState(null); const scrollRef = useRef(null); + const copyFeedbackTimerRef = useRef | null>(null); const fetchLogs = useCallback(async () => { try { @@ -88,6 +90,13 @@ export default function ConsoleLogViewer() { }; }, [fetchLogs]); + useEffect( + () => () => { + if (copyFeedbackTimerRef.current) clearTimeout(copyFeedbackTimerRef.current); + }, + [] + ); + // Auto-scroll to bottom on new logs useEffect(() => { if (autoScroll && scrollRef.current) { @@ -104,8 +113,12 @@ export default function ConsoleLogViewer() { } setError(null); + if (copyFeedbackTimerRef.current) clearTimeout(copyFeedbackTimerRef.current); setCopiedIdx(idx); - setTimeout(() => setCopiedIdx(null), 2000); + copyFeedbackTimerRef.current = setTimeout(() => { + copyFeedbackTimerRef.current = null; + setCopiedIdx(null); + }, 2000); }; const formatTime = (ts: string) => { @@ -197,9 +210,12 @@ export default function ConsoleLogViewer() { {/* Status */} @@ -302,12 +318,18 @@ export default function ConsoleLogViewer() { + {copiedIdx === idx && ( + + {tc("copied")} + + )} ); }) diff --git a/tests/unit/ui/console-log-viewer-accessibility.test.tsx b/tests/unit/ui/console-log-viewer-accessibility.test.tsx new file mode 100644 index 0000000000..605584b04d --- /dev/null +++ b/tests/unit/ui/console-log-viewer-accessibility.test.tsx @@ -0,0 +1,141 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const copyToClipboard = vi.fn(); + +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: (namespace: string) => (key: string) => `${namespace}.${key}`, +})); + +vi.mock("@/shared/utils/clipboard", () => ({ copyToClipboard })); + +const roots: Root[] = []; + +async function renderViewer() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + + const { default: ConsoleLogViewer } = + await import("../../../src/shared/components/ConsoleLogViewer"); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(0); + await Promise.resolve(); + }); + + return container; +} + +describe("ConsoleLogViewer accessibility", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + copyToClipboard.mockResolvedValue(true); + vi.useFakeTimers(); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => [ + { + timestamp: "2026-08-26T00:00:00.000Z", + level: "info", + message: "ready", + }, + { + timestamp: "2026-08-26T00:00:01.000Z", + level: "warn", + message: "waiting", + }, + ], + }); + }); + + afterEach(() => { + for (const root of roots.splice(0)) { + act(() => root.unmount()); + } + document.body.innerHTML = ""; + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("names icon-only controls and exposes keyboard-visible copy feedback", async () => { + const container = await renderViewer(); + const refresh = container.querySelector( + 'button[aria-label="common.refresh"]' + ); + const copy = container.querySelector( + 'button[aria-label="logs.consoleViewer.copyLogEntry"]' + ); + + expect(refresh).not.toBeNull(); + expect(refresh?.querySelector(".material-symbols-outlined")?.getAttribute("aria-hidden")).toBe( + "true" + ); + expect(copy).not.toBeNull(); + expect(copy?.className).toContain("focus-visible:opacity-100"); + expect(copy?.querySelector(".material-symbols-outlined")?.getAttribute("aria-hidden")).toBe( + "true" + ); + + await act(async () => { + copy?.click(); + await Promise.resolve(); + }); + + const status = container.querySelector('[role="status"][aria-live="polite"]'); + expect(status?.textContent).toBe("common.copied"); + await act(async () => { + vi.advanceTimersByTime(2000); + }); + expect(container.querySelector('[role="status"]')).toBeNull(); + }); + + it("keeps the latest copy announcement for its full timeout", async () => { + const container = await renderViewer(); + const buttons = container.querySelectorAll( + 'button[aria-label="logs.consoleViewer.copyLogEntry"]' + ); + expect(buttons).toHaveLength(2); + + await act(async () => { + buttons[0].click(); + await Promise.resolve(); + vi.advanceTimersByTime(1000); + buttons[1].click(); + await Promise.resolve(); + vi.advanceTimersByTime(1000); + }); + expect(container.querySelector('[role="status"]')?.textContent).toBe("common.copied"); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + expect(container.querySelector('[role="status"]')).toBeNull(); + }); + + it("clears pending copy feedback when unmounted", async () => { + const container = await renderViewer(); + const copy = container.querySelector( + 'button[aria-label="logs.consoleViewer.copyLogEntry"]' + ); + + await act(async () => { + copy?.click(); + await Promise.resolve(); + }); + + const root = roots.pop(); + act(() => root?.unmount()); + expect(vi.getTimerCount()).toBe(0); + }); +});