From 33d716edfbdf6785e3ed487c6343d03331d66c73 Mon Sep 17 00:00:00 2001
From: Victor Nwokenekwu <69258253+vrickish@users.noreply.github.com>
Date: Thu, 16 Jul 2026 12:30:04 +0000
Subject: [PATCH 1/3] feat: add Copied! confirmation with check icon and i18n
to CopyButton
---
src/components/CopyButton.test.tsx | 137 ++++++++++++++++++++++++++++-
src/components/CopyButton.tsx | 33 +++++--
src/hooks/useCopyToClipboard.ts | 2 +-
src/i18n/locales/ar.json | 5 ++
src/i18n/locales/de.json | 5 ++
src/i18n/locales/en.json | 5 ++
src/i18n/locales/es.json | 5 ++
src/i18n/locales/fr.json | 5 ++
src/i18n/locales/ja.json | 5 ++
src/i18n/locales/ko.json | 5 ++
src/i18n/locales/zh.json | 5 ++
11 files changed, 202 insertions(+), 10 deletions(-)
diff --git a/src/components/CopyButton.test.tsx b/src/components/CopyButton.test.tsx
index ea059d9..870f25e 100644
--- a/src/components/CopyButton.test.tsx
+++ b/src/components/CopyButton.test.tsx
@@ -1,4 +1,5 @@
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { render } from "../test/utils";
import CopyButton from "./CopyButton";
describe("CopyButton", () => {
@@ -25,11 +26,120 @@ describe("CopyButton", () => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("0xabc123");
});
- expect(screen.getByRole("button", { name: "Copy hash" })).toHaveTextContent(
+ expect(screen.getByRole("button", { name: /Copy hash/ })).toHaveTextContent(
"Copied"
);
});
+ it("shows check icon when copy succeeds", async () => {
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Copy" }));
+
+ await waitFor(() => {
+ // The check icon is rendered inside the button with aria-hidden
+ const button = screen.getByRole("button", { name: /Copy/ });
+ expect(button.querySelector("svg")).toBeTruthy();
+ });
+ });
+
+ it("updates aria-label to include copied state for screen readers", async () => {
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Copy" }));
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("button", { name: "Copy - Copied!" })
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("announces copy status to screen readers via aria-live region", async () => {
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Copy" }));
+
+ await waitFor(() => {
+ const liveRegion = screen.getByRole("status");
+ expect(liveRegion).toHaveAttribute("aria-live", "polite");
+ expect(liveRegion).toHaveTextContent("Copied to clipboard");
+ });
+ });
+
+ it("reverts to original label after success duration", async () => {
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Copy" }));
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("button", { name: "Copy - Copied!" })
+ ).toHaveTextContent("Copied!");
+ });
+
+ // Wait for the success duration to expire and label to revert
+ await waitFor(
+ () => {
+ expect(screen.getByRole("button", { name: "Copy" })).toHaveTextContent(
+ "Copy"
+ );
+ },
+ { timeout: 3000 }
+ );
+ });
+
+ it("handles rapid repeated clicks without visual glitches", async () => {
+ render(
+
+ );
+
+ const button = screen.getByRole("button", { name: "Copy" });
+
+ // Click rapidly multiple times
+ fireEvent.click(button);
+ fireEvent.click(button);
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ // Should show success state after the last click
+ expect(button).toHaveTextContent("Copied!");
+ });
+
+ // After the duration, should revert once
+ await waitFor(
+ () => {
+ expect(button).toHaveTextContent("Copy");
+ },
+ { timeout: 3000 }
+ );
+ });
+
+ it("uses i18n default labels when no explicit labels provided", async () => {
+ render();
+
+ const button = screen.getByRole("button", { name: "Copy" });
+ expect(button).toHaveTextContent("Copy");
+
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ expect(button).toHaveTextContent("Copied!");
+ });
+ });
+
it("supports keyboard shortcut copy while focused", async () => {
render();
@@ -64,4 +174,27 @@ describe("CopyButton", () => {
);
});
});
+
+ it("does not show check icon when copy fails", async () => {
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: {
+ writeText: vi.fn().mockRejectedValue(new Error("copy denied")),
+ },
+ });
+
+ Object.defineProperty(document, "execCommand", {
+ configurable: true,
+ value: vi.fn(() => false),
+ });
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Copy" }));
+
+ await waitFor(() => {
+ const button = screen.getByRole("button", { name: "Copy" });
+ expect(button.querySelector("svg")).toBeFalsy();
+ });
+ });
});
diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx
index 3d7bd88..929af9e 100644
--- a/src/components/CopyButton.tsx
+++ b/src/components/CopyButton.tsx
@@ -1,4 +1,6 @@
import type { KeyboardEvent, MouseEvent } from "react";
+import { useTranslation } from "react-i18next";
+import { CheckIcon } from "@heroicons/react/24/outline";
import useCopyToClipboard, {
type CopyFormat,
type CopyOptions,
@@ -22,9 +24,9 @@ interface CopyButtonProps {
export default function CopyButton({
value,
- label = "Copy",
- copiedLabel = "Copied",
- failedLabel = "Failed",
+ label,
+ copiedLabel,
+ failedLabel,
className = "",
format = "text",
mimeType,
@@ -35,12 +37,17 @@ export default function CopyButton({
stopPropagation = true,
ariaLabel,
}: CopyButtonProps) {
+ const { t } = useTranslation();
const { copy, status, message } = useCopyToClipboard();
+ const resolvedLabel = label ?? t("copyButton.copy", "Copy");
+ const resolvedCopiedLabel = copiedLabel ?? t("copyButton.copied", "Copied!");
+ const resolvedFailedLabel = failedLabel ?? t("copyButton.failed", "Failed");
+
const buttonBaseClass =
variant === "inline"
? "text-xs font-medium text-stellar-blue hover:text-stellar-text-primary underline underline-offset-2 focus:outline-none focus:ring-2 focus:ring-stellar-blue rounded px-1 py-0.5"
- : "inline-flex items-center justify-center min-h-9 px-3 py-1.5 text-xs font-medium rounded-md border border-stellar-border text-stellar-text-secondary hover:text-stellar-text-primary hover:border-stellar-blue focus:outline-none focus:ring-2 focus:ring-stellar-blue transition-colors";
+ : "inline-flex items-center justify-center gap-1.5 min-h-9 px-3 py-1.5 text-xs font-medium rounded-md border border-stellar-border text-stellar-text-secondary hover:text-stellar-text-primary hover:border-stellar-blue focus:outline-none focus:ring-2 focus:ring-stellar-blue transition-colors";
const handleCopy = async (event?: MouseEvent | KeyboardEvent) => {
if (stopPropagation && event) {
@@ -68,7 +75,14 @@ export default function CopyButton({
};
const visibleLabel =
- status === "success" ? copiedLabel : status === "error" ? failedLabel : label;
+ status === "success"
+ ? resolvedCopiedLabel
+ : status === "error"
+ ? resolvedFailedLabel
+ : resolvedLabel;
+
+ const isSuccess = status === "success";
+ const buttonAriaLabel = ariaLabel ?? resolvedLabel;
return (
@@ -79,9 +93,14 @@ export default function CopyButton({
}}
onKeyDown={handleKeyDown}
className={`${buttonBaseClass} ${className}`.trim()}
- aria-label={ariaLabel ?? label}
+ aria-label={isSuccess ? `${buttonAriaLabel} - ${resolvedCopiedLabel}` : buttonAriaLabel}
>
- {visibleLabel}
+ {isSuccess && (
+
+ )}
+
+ {visibleLabel}
+
{message}
diff --git a/src/hooks/useCopyToClipboard.ts b/src/hooks/useCopyToClipboard.ts
index cb316fa..18174e1 100644
--- a/src/hooks/useCopyToClipboard.ts
+++ b/src/hooks/useCopyToClipboard.ts
@@ -15,7 +15,7 @@ interface CopyState {
message: string;
}
-const DEFAULT_SUCCESS_DURATION_MS = 2000;
+const DEFAULT_SUCCESS_DURATION_MS = 1500;
function toText(value: unknown, format: CopyFormat): string {
if (value === null || value === undefined) {
diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json
index 48136ea..46b1e2f 100644
--- a/src/i18n/locales/ar.json
+++ b/src/i18n/locales/ar.json
@@ -189,6 +189,11 @@
"exactTitle": "آخر تحديث: {{timestamp}}",
"ariaLabel": "الاتصال: {{status}}. {{updated}}"
},
+ "copyButton": {
+ "copy": "نسخ",
+ "copied": "تم النسخ!",
+ "failed": "فشل"
+ },
"app": {
"loadingPage": "جارٍ تحميل الصفحة..."
}
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json
index 36e018d..ed942ff 100644
--- a/src/i18n/locales/de.json
+++ b/src/i18n/locales/de.json
@@ -189,6 +189,11 @@
"exactTitle": "Zuletzt aktualisiert: {{timestamp}}",
"ariaLabel": "Verbindung: {{status}}. {{updated}}"
},
+ "copyButton": {
+ "copy": "Kopieren",
+ "copied": "Kopiert!",
+ "failed": "Fehlgeschlagen"
+ },
"app": {
"loadingPage": "Seite wird geladen..."
}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 751a3c8..6ae2806 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -189,6 +189,11 @@
"exactTitle": "Last updated: {{timestamp}}",
"ariaLabel": "Connection: {{status}}. {{updated}}"
},
+ "copyButton": {
+ "copy": "Copy",
+ "copied": "Copied!",
+ "failed": "Failed"
+ },
"app": {
"loadingPage": "Loading page..."
}
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index 8499e08..58dbd13 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -189,6 +189,11 @@
"exactTitle": "Última actualización: {{timestamp}}",
"ariaLabel": "Conexión: {{status}}. {{updated}}"
},
+ "copyButton": {
+ "copy": "Copiar",
+ "copied": "¡Copiado!",
+ "failed": "Falló"
+ },
"app": {
"loadingPage": "Cargando página..."
}
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index e2b09c7..8612d10 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -189,6 +189,11 @@
"exactTitle": "Dernière mise à jour : {{timestamp}}",
"ariaLabel": "Connexion : {{status}}. {{updated}}"
},
+ "copyButton": {
+ "copy": "Copier",
+ "copied": "Copié !",
+ "failed": "Échec"
+ },
"app": {
"loadingPage": "Chargement de la page..."
}
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 5a773b2..01cb717 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -189,6 +189,11 @@
"exactTitle": "最終更新: {{timestamp}}",
"ariaLabel": "接続: {{status}}。{{updated}}"
},
+ "copyButton": {
+ "copy": "コピー",
+ "copied": "コピーしました!",
+ "failed": "失敗"
+ },
"app": {
"loadingPage": "ページを読み込み中..."
}
diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json
index 0b35f47..1947ce8 100644
--- a/src/i18n/locales/ko.json
+++ b/src/i18n/locales/ko.json
@@ -189,6 +189,11 @@
"exactTitle": "마지막 업데이트: {{timestamp}}",
"ariaLabel": "연결: {{status}}. {{updated}}"
},
+ "copyButton": {
+ "copy": "복사",
+ "copied": "복사됨!",
+ "failed": "실패"
+ },
"app": {
"loadingPage": "페이지 로딩 중..."
}
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index c307a72..d11a3fa 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -189,6 +189,11 @@
"exactTitle": "最后更新:{{timestamp}}",
"ariaLabel": "连接:{{status}}。{{updated}}"
},
+ "copyButton": {
+ "copy": "复制",
+ "copied": "已复制!",
+ "failed": "失败"
+ },
"app": {
"loadingPage": "正在加载页面..."
}
From e7504f85a702d7fb1ff155675ded167de2b0472a Mon Sep 17 00:00:00 2001
From: Victor Nwokenekwu <69258253+vrickish@users.noreply.github.com>
Date: Thu, 16 Jul 2026 14:42:08 +0000
Subject: [PATCH 2/3] fix: resolve ESLint errors in CI
---
src/components/CommandPalette.test.tsx | 276 +++++++++++++++++++++++++
src/context/AuthContext.tsx | 1 +
src/pages/OperationsConsole.tsx | 9 +-
3 files changed, 284 insertions(+), 2 deletions(-)
create mode 100644 src/components/CommandPalette.test.tsx
diff --git a/src/components/CommandPalette.test.tsx b/src/components/CommandPalette.test.tsx
new file mode 100644
index 0000000..80d25a6
--- /dev/null
+++ b/src/components/CommandPalette.test.tsx
@@ -0,0 +1,276 @@
+/**
+ * Tests for the accessible CommandPalette component.
+ *
+ * Covers:
+ * - Keyboard navigation (Up/Down/Enter/Escape)
+ * - ARIA roles and attributes (combobox, listbox, option, aria-activedescendant)
+ * - Live-region announcements for result counts
+ * - Focus trap while open and focus restoration on close
+ * - Cmd/Ctrl+K global toggle
+ */
+import { screen, fireEvent, waitFor } from "../test/utils";
+import { render } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import CommandPalette from "./CommandPalette";
+import { actionsRegistry, registerAction } from "../utils/commandRegistry";
+
+// ---------------------------------------------------------------------------
+// Test helpers
+// ---------------------------------------------------------------------------
+
+function renderPalette() {
+ return render(
+
+
+ ,
+ );
+}
+
+/** Open the palette with Ctrl+K */
+async function openPalette() {
+ fireEvent.keyDown(window, { key: "k", ctrlKey: true });
+}
+
+// ---------------------------------------------------------------------------
+// Seed the registry with deterministic test actions
+// ---------------------------------------------------------------------------
+const TEST_ACTIONS = [
+ { id: "action-dashboard", title: "Go to Dashboard", href: "/dashboard", keywords: ["home"] },
+ { id: "action-bridges", title: "View Bridges", href: "/bridges", keywords: ["bridge"] },
+ { id: "action-settings", title: "Open Settings", href: "/settings", keywords: ["config"] },
+];
+
+beforeAll(() => {
+ actionsRegistry.length = 0;
+ TEST_ACTIONS.forEach((a) => registerAction(a));
+});
+
+afterAll(() => {
+ actionsRegistry.length = 0;
+});
+
+beforeEach(() => {
+ localStorage.clear();
+});
+
+// ---------------------------------------------------------------------------
+// Opening and closing
+// ---------------------------------------------------------------------------
+
+describe("CommandPalette – open / close", () => {
+ it("is hidden by default", () => {
+ renderPalette();
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ it("opens when Ctrl+K is pressed", async () => {
+ renderPalette();
+ await openPalette();
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ });
+
+ it("closes when Escape is pressed", async () => {
+ renderPalette();
+ await openPalette();
+ const input = screen.getByRole("combobox");
+ fireEvent.keyDown(input, { key: "Escape" });
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ it("closes when backdrop is clicked", async () => {
+ renderPalette();
+ await openPalette();
+ const backdrop = document.querySelector(".absolute.inset-0.bg-black\\/60") as HTMLElement;
+ expect(backdrop).not.toBeNull();
+ fireEvent.click(backdrop);
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ it("toggles closed with Ctrl+K when already open", async () => {
+ renderPalette();
+ await openPalette();
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ fireEvent.keyDown(window, { key: "k", ctrlKey: true });
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// ARIA roles and attributes
+// ---------------------------------------------------------------------------
+
+describe("CommandPalette – ARIA", () => {
+ it("has combobox role on the search input", async () => {
+ renderPalette();
+ await openPalette();
+ expect(screen.getByRole("combobox")).toBeInTheDocument();
+ });
+
+ it("renders options with listbox role", async () => {
+ renderPalette();
+ await openPalette();
+ const listbox = screen.getByRole("listbox");
+ expect(listbox).toBeInTheDocument();
+ const options = screen.getAllByRole("option");
+ expect(options.length).toBeGreaterThan(0);
+ });
+
+
+ it("announces result count via live region", async () => {
+ renderPalette();
+ await openPalette();
+ const input = screen.getByRole("combobox");
+ fireEvent.change(input, { target: { value: "dashboard" } });
+ await waitFor(() =>
+ expect(screen.getByText(/1 result/)).toBeInTheDocument(),
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Keyboard navigation
+// ---------------------------------------------------------------------------
+
+describe("CommandPalette – keyboard navigation", () => {
+ it("navigates items with ArrowDown", async () => {
+ renderPalette();
+ await openPalette();
+ const input = screen.getByRole("combobox");
+ fireEvent.keyDown(input, { key: "ArrowDown" });
+ const options = screen.getAllByRole("option");
+ await waitFor(() =>
+ expect(options[0]).toHaveAttribute("aria-selected", "true"),
+ );
+ });
+
+ it("executes selected item on Enter", async () => {
+ const onExecute = vi.fn();
+ actionsRegistry.length = 0;
+ registerAction({ id: "enter-action", title: "Enter Me", onExecute });
+ renderPalette();
+ await openPalette();
+ const input = screen.getByRole("combobox");
+ fireEvent.change(input, { target: { value: "Enter" } });
+ fireEvent.keyDown(input, { key: "ArrowDown" });
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(onExecute).toHaveBeenCalledOnce();
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ actionsRegistry.length = 0;
+ TEST_ACTIONS.forEach((a) => registerAction(a));
+ });
+});
+
+
+// ---------------------------------------------------------------------------
+// Focus management
+// ---------------------------------------------------------------------------
+
+describe("CommandPalette – focus management", () => {
+ it("restores focus to the previously focused element on close", async () => {
+ const { container } = render(
+
+
+
+ ,
+ );
+
+ const trigger = container.querySelector("[data-testid='trigger']") as HTMLElement;
+ trigger.focus();
+ expect(document.activeElement).toBe(trigger);
+
+ await openPalette();
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+
+ const input = screen.getByRole("combobox");
+ fireEvent.keyDown(input, { key: "Escape" });
+
+ await waitFor(() =>
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
+ );
+ await waitFor(() => expect(document.activeElement).toBe(trigger));
+ });
+
+ it("focuses the input when the palette opens", async () => {
+ renderPalette();
+ await openPalette();
+ await waitFor(() =>
+ expect(document.activeElement).toBe(screen.getByRole("combobox")),
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Mouse interaction
+// ---------------------------------------------------------------------------
+
+describe("CommandPalette – mouse interaction", () => {
+ it("hovering an item makes it active", async () => {
+ renderPalette();
+ await openPalette();
+ const input = screen.getByRole("combobox");
+ fireEvent.change(input, { target: { value: "e" } });
+
+ const getOptions = () =>
+ screen.getAllByRole("option").filter((o) => !o.hasAttribute("aria-disabled"));
+ expect(getOptions().length).toBeGreaterThanOrEqual(2);
+
+ fireEvent.mouseEnter(getOptions()[1]);
+ expect(getOptions()[1]).toHaveAttribute("aria-selected", "true");
+ });
+
+ it("clicking an item executes it and closes the palette", async () => {
+ const onExecute = vi.fn();
+ actionsRegistry.length = 0;
+ registerAction({ id: "click-action", title: "Click Me", onExecute });
+
+ renderPalette();
+ await openPalette();
+ const input = screen.getByRole("combobox");
+ fireEvent.change(input, { target: { value: "Click" } });
+
+ const options = screen
+ .getAllByRole("option")
+ .filter((o) => !o.hasAttribute("aria-disabled"));
+ fireEvent.click(options[0]);
+
+ expect(onExecute).toHaveBeenCalledOnce();
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+
+ actionsRegistry.length = 0;
+ TEST_ACTIONS.forEach((a) => registerAction(a));
+ });
+});
+
+
+// ---------------------------------------------------------------------------
+// Recent actions
+// ---------------------------------------------------------------------------
+
+describe("CommandPalette – recent actions", () => {
+ it("shows 'Recent' section label when there are recent actions and query is empty", async () => {
+ localStorage.setItem(
+ "swipely:recent_actions",
+ JSON.stringify(["action-dashboard"]),
+ );
+ renderPalette();
+ await openPalette();
+ expect(screen.getByText("Recent")).toBeInTheDocument();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// "No results" empty state
+// ---------------------------------------------------------------------------
+
+describe("CommandPalette – empty state", () => {
+ it("shows a 'No results' option when nothing matches the query", async () => {
+ renderPalette();
+ await openPalette();
+ const input = screen.getByRole("combobox");
+ fireEvent.change(input, { target: { value: "xyznotexist" } });
+
+ await waitFor(() =>
+ expect(screen.getByText("No results")).toBeInTheDocument(),
+ );
+ });
+});
diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx
index 1c5c25e..19cc53c 100644
--- a/src/context/AuthContext.tsx
+++ b/src/context/AuthContext.tsx
@@ -37,6 +37,7 @@ export const MockAuthProvider: React.FC<{ children: React.ReactNode; user?: User
return {children};
};
+// eslint-disable-next-line react-refresh/only-export-components
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
diff --git a/src/pages/OperationsConsole.tsx b/src/pages/OperationsConsole.tsx
index e26b255..1414791 100644
--- a/src/pages/OperationsConsole.tsx
+++ b/src/pages/OperationsConsole.tsx
@@ -78,7 +78,7 @@ function OperationsConsole() {
const filtered = ACTIONS.filter((a) => a.label.toLowerCase().includes(query.toLowerCase()) || (a.description ?? "").toLowerCase().includes(query.toLowerCase()));
- const handleExecute = async (action: any) => {
+ const handleExecute = async (action: typeof ACTIONS[number]) => {
if (action.destructive && action.confirmationPhrase) {
setModal({ actionId: action.id, phrase: action.confirmationPhrase });
return;
@@ -183,7 +183,12 @@ function OperationsConsole() {
);
}
-function ActionWrapper({ action, onExecute }: any) {
+interface ActionWrapperProps {
+ action: typeof ACTIONS[number];
+ onExecute: (action: typeof ACTIONS[number]) => Promise;
+}
+
+function ActionWrapper({ action, onExecute }: ActionWrapperProps) {
return (
);
From 81cb144035b5638c224e18ec9e4e009ae809c7cf Mon Sep 17 00:00:00 2001
From: Victor Nwokenekwu <69258253+vrickish@users.noreply.github.com>
Date: Thu, 16 Jul 2026 14:45:29 +0000
Subject: [PATCH 3/3] fix: resolve merge conflict - use upstream file minus
unused import
---
src/components/CommandPalette.test.tsx | 150 +++++++++++++++++++++----
1 file changed, 130 insertions(+), 20 deletions(-)
diff --git a/src/components/CommandPalette.test.tsx b/src/components/CommandPalette.test.tsx
index 80d25a6..e0f9ecf 100644
--- a/src/components/CommandPalette.test.tsx
+++ b/src/components/CommandPalette.test.tsx
@@ -41,6 +41,8 @@ const TEST_ACTIONS = [
];
beforeAll(() => {
+ // Populate the registry only with test actions (it is a module-level array)
+ // so we splice-in our entries to avoid cross-test pollution.
actionsRegistry.length = 0;
TEST_ACTIONS.forEach((a) => registerAction(a));
});
@@ -80,6 +82,8 @@ describe("CommandPalette – open / close", () => {
it("closes when backdrop is clicked", async () => {
renderPalette();
await openPalette();
+ // The backdrop div is the first child of the dialog's fixed container;
+ // clicking the fixed container (which has an onClick guard) also closes.
const backdrop = document.querySelector(".absolute.inset-0.bg-black\\/60") as HTMLElement;
expect(backdrop).not.toBeNull();
fireEvent.click(backdrop);
@@ -100,29 +104,74 @@ describe("CommandPalette – open / close", () => {
// ---------------------------------------------------------------------------
describe("CommandPalette – ARIA", () => {
- it("has combobox role on the search input", async () => {
+ it("dialog has role=dialog, aria-modal and aria-label", async () => {
renderPalette();
await openPalette();
- expect(screen.getByRole("combobox")).toBeInTheDocument();
+ const dialog = screen.getByRole("dialog");
+ expect(dialog).toHaveAttribute("aria-modal", "true");
+ expect(dialog).toHaveAttribute("aria-label", "Command palette");
});
- it("renders options with listbox role", async () => {
+ it("input has role=combobox with correct aria attributes", async () => {
renderPalette();
await openPalette();
- const listbox = screen.getByRole("listbox");
- expect(listbox).toBeInTheDocument();
+ const input = screen.getByRole("combobox");
+ expect(input).toHaveAttribute("aria-autocomplete", "list");
+ expect(input).toHaveAttribute("aria-label", "Search commands");
+ });
+
+ it("listbox has role=listbox", async () => {
+ renderPalette();
+ await openPalette();
+ expect(screen.getByRole("listbox")).toBeInTheDocument();
+ });
+
+ it("each result item has role=option", async () => {
+ renderPalette();
+ await openPalette();
+ // Type something to show results
+ const input = screen.getByRole("combobox");
+ fireEvent.change(input, { target: { value: "dashboard" } });
const options = screen.getAllByRole("option");
- expect(options.length).toBeGreaterThan(0);
+ // "No results" empty state is also role=option; filter by non-disabled
+ const realOptions = options.filter((o) => !o.hasAttribute("aria-disabled"));
+ expect(realOptions.length).toBeGreaterThan(0);
});
+ it("aria-activedescendant points to the active option", async () => {
+ renderPalette();
+ await openPalette();
+ const input = screen.getByRole("combobox");
+ fireEvent.change(input, { target: { value: "dashboard" } });
- it("announces result count via live region", async () => {
+ const input2 = screen.getByRole("combobox");
+ const activeDescendant = input2.getAttribute("aria-activedescendant");
+ expect(activeDescendant).toBeTruthy();
+
+ const activeOption = document.getElementById(activeDescendant!);
+ expect(activeOption).not.toBeNull();
+ expect(activeOption).toHaveAttribute("aria-selected", "true");
+ });
+
+ it("live region announces result count", async () => {
renderPalette();
await openPalette();
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "dashboard" } });
+
+ const status = screen.getByRole("status");
+ expect(status.textContent).toMatch(/result/i);
+ });
+
+ it("live region announces 'No results' when nothing matches", async () => {
+ renderPalette();
+ await openPalette();
+ const input = screen.getByRole("combobox");
+ fireEvent.change(input, { target: { value: "xyznotexist" } });
+
+ const status = screen.getByRole("status");
await waitFor(() =>
- expect(screen.getByText(/1 result/)).toBeInTheDocument(),
+ expect(status.textContent).toMatch(/no results found/i),
);
});
});
@@ -132,37 +181,92 @@ describe("CommandPalette – ARIA", () => {
// ---------------------------------------------------------------------------
describe("CommandPalette – keyboard navigation", () => {
- it("navigates items with ArrowDown", async () => {
+ it("ArrowDown moves highlight to the next item", async () => {
renderPalette();
await openPalette();
const input = screen.getByRole("combobox");
+ // "e" appears in all three test actions (Dashboard, Bridges, Settings)
+ fireEvent.change(input, { target: { value: "e" } });
+
+ const getOptions = () =>
+ screen.getAllByRole("option").filter((o) => !o.hasAttribute("aria-disabled"));
+
+ expect(getOptions().length).toBeGreaterThanOrEqual(2);
+ expect(getOptions()[0]).toHaveAttribute("aria-selected", "true");
fireEvent.keyDown(input, { key: "ArrowDown" });
- const options = screen.getAllByRole("option");
- await waitFor(() =>
- expect(options[0]).toHaveAttribute("aria-selected", "true"),
- );
+ // Re-query after re-render
+ expect(getOptions()[1]).toHaveAttribute("aria-selected", "true");
});
- it("executes selected item on Enter", async () => {
+ it("ArrowUp moves highlight to the previous item", async () => {
+ renderPalette();
+ await openPalette();
+ const input = screen.getByRole("combobox");
+ fireEvent.change(input, { target: { value: "e" } });
+
+ // Move down first then back up
+ fireEvent.keyDown(input, { key: "ArrowDown" });
+ fireEvent.keyDown(input, { key: "ArrowUp" });
+
+ const options = screen
+ .getAllByRole("option")
+ .filter((o) => !o.hasAttribute("aria-disabled"));
+ expect(options[0]).toHaveAttribute("aria-selected", "true");
+ });
+
+ it("ArrowDown does not go past the last item", async () => {
+ renderPalette();
+ await openPalette();
+ const input = screen.getByRole("combobox");
+ fireEvent.change(input, { target: { value: "e" } });
+
+ const getOptions = () =>
+ screen.getAllByRole("option").filter((o) => !o.hasAttribute("aria-disabled"));
+
+ const count = getOptions().length;
+ for (let i = 0; i < count + 5; i++) {
+ fireEvent.keyDown(input, { key: "ArrowDown" });
+ }
+ expect(getOptions()[count - 1]).toHaveAttribute("aria-selected", "true");
+ });
+
+ it("ArrowUp does not go above the first item", async () => {
+ renderPalette();
+ await openPalette();
+ const input = screen.getByRole("combobox");
+ fireEvent.change(input, { target: { value: "e" } });
+
+ for (let i = 0; i < 5; i++) {
+ fireEvent.keyDown(input, { key: "ArrowUp" });
+ }
+ const options = screen
+ .getAllByRole("option")
+ .filter((o) => !o.hasAttribute("aria-disabled"));
+ expect(options[0]).toHaveAttribute("aria-selected", "true");
+ });
+
+ it("Enter executes the currently active action and closes the palette", async () => {
const onExecute = vi.fn();
actionsRegistry.length = 0;
- registerAction({ id: "enter-action", title: "Enter Me", onExecute });
+ registerAction({ id: "test-action", title: "Test Action", onExecute });
+
renderPalette();
await openPalette();
const input = screen.getByRole("combobox");
- fireEvent.change(input, { target: { value: "Enter" } });
- fireEvent.keyDown(input, { key: "ArrowDown" });
+ fireEvent.change(input, { target: { value: "Test" } });
fireEvent.keyDown(input, { key: "Enter" });
+
expect(onExecute).toHaveBeenCalledOnce();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+
+ // Restore for other tests
actionsRegistry.length = 0;
TEST_ACTIONS.forEach((a) => registerAction(a));
});
});
-
// ---------------------------------------------------------------------------
-// Focus management
+// Focus restoration
// ---------------------------------------------------------------------------
describe("CommandPalette – focus management", () => {
@@ -187,6 +291,7 @@ describe("CommandPalette – focus management", () => {
await waitFor(() =>
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
);
+ // rAF-deferred focus; give it a tick
await waitFor(() => expect(document.activeElement).toBe(trigger));
});
@@ -208,13 +313,17 @@ describe("CommandPalette – mouse interaction", () => {
renderPalette();
await openPalette();
const input = screen.getByRole("combobox");
+ // "e" appears in all three test actions (Dashboard, Bridges, Settings)
fireEvent.change(input, { target: { value: "e" } });
const getOptions = () =>
screen.getAllByRole("option").filter((o) => !o.hasAttribute("aria-disabled"));
+
+ // Ensure there are at least 2 options
expect(getOptions().length).toBeGreaterThanOrEqual(2);
fireEvent.mouseEnter(getOptions()[1]);
+ // Re-query after state update
expect(getOptions()[1]).toHaveAttribute("aria-selected", "true");
});
@@ -241,19 +350,20 @@ describe("CommandPalette – mouse interaction", () => {
});
});
-
// ---------------------------------------------------------------------------
// Recent actions
// ---------------------------------------------------------------------------
describe("CommandPalette – recent actions", () => {
it("shows 'Recent' section label when there are recent actions and query is empty", async () => {
+ // Pre-seed localStorage
localStorage.setItem(
"swipely:recent_actions",
JSON.stringify(["action-dashboard"]),
);
renderPalette();
await openPalette();
+ // The label is aria-hidden so query by text directly
expect(screen.getByText("Recent")).toBeInTheDocument();
});
});