mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-18 21:02:50 +03:00
fix(ui): make console log controls accessible (#11599)
Merged via /merge-batch (lote 2026-08-26, v3.8.51). Boarded no worktree combinado junto com outras ~30 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e ~370 testes focados (unit + vitest) passando. Obrigado pela contribuição.
This commit is contained in:
@@ -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
|
||||
@@ -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<LogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -57,6 +58,7 @@ export default function ConsoleLogViewer() {
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [copiedIdx, setCopiedIdx] = useState<number | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | 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() {
|
||||
<button
|
||||
onClick={fetchLogs}
|
||||
disabled={loading}
|
||||
aria-label={tc("refresh")}
|
||||
className="px-3 py-2 rounded-lg text-sm font-medium bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px] align-middle">refresh</span>
|
||||
<span className="material-symbols-outlined text-[16px] align-middle" aria-hidden="true">
|
||||
refresh
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Status */}
|
||||
@@ -302,12 +318,18 @@ export default function ConsoleLogViewer() {
|
||||
<button
|
||||
onClick={() => handleCopy(entry, idx)}
|
||||
title={tv("copyLogEntry")}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity shrink-0 text-[#8b949e] hover:text-white"
|
||||
aria-label={tv("copyLogEntry")}
|
||||
className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity shrink-0 text-[#8b949e] hover:text-white"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
|
||||
{copiedIdx === idx ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
{copiedIdx === idx && (
|
||||
<span className="sr-only" role="status" aria-live="polite">
|
||||
{tc("copied")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
141
tests/unit/ui/console-log-viewer-accessibility.test.tsx
Normal file
141
tests/unit/ui/console-log-viewer-accessibility.test.tsx
Normal file
@@ -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(<ConsoleLogViewer />);
|
||||
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<HTMLButtonElement>(
|
||||
'button[aria-label="common.refresh"]'
|
||||
);
|
||||
const copy = container.querySelector<HTMLButtonElement>(
|
||||
'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<HTMLButtonElement>(
|
||||
'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<HTMLButtonElement>(
|
||||
'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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user