From 466f040f88b0cd17b2870ee649a751cc2938a6d3 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-UIUX Date: Mon, 18 May 2026 01:34:26 +0000 Subject: [PATCH 1/2] fix(canvas): complete ARIA tab pattern for ExternalConnectModal (WCAG) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add id=, aria-controls=, and tabIndex= to each role=tab button - Add id= and role=tabpanel + aria-labelledby= to each snippet panel - Restructure panels as always-rendered (hidden CSS) so aria-controls targets are stable — active panel has role=tabpanel, hidden panels are hidden with aria-hidden semantics via hidden attribute - Add ArrowRight/ArrowLeft/ArrowDown/ArrowUp + Home/End keyboard navigation for the tablist (ARIA tab pattern requirement) - Compute tabList once after filled* vars to share between tab bar and keyboard handler WCAG 4.1.3 (Name, Role, Value) — tab controls now have correct role, aria-selected, aria-controls, and keyboard navigation. Co-Authored-By: Claude Opus 4.7 --- .../src/components/ExternalConnectModal.tsx | 270 ++++++++++++------ 1 file changed, 186 insertions(+), 84 deletions(-) diff --git a/canvas/src/components/ExternalConnectModal.tsx b/canvas/src/components/ExternalConnectModal.tsx index 89ff25249..71fa1f7f4 100644 --- a/canvas/src/components/ExternalConnectModal.tsx +++ b/canvas/src/components/ExternalConnectModal.tsx @@ -15,7 +15,7 @@ // ($AGENT_URL). They ARE NOT filled in server-side because the // server doesn't know where the operator's agent will live. -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import * as Dialog from "@radix-ui/react-dialog"; type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "kimi" | "fields"; @@ -84,6 +84,33 @@ export function ExternalConnectModal({ info, onClose }: Props) { : "python"; const [tab, setTab] = useState(initialTab); const [copiedKey, setCopiedKey] = useState(null); + const tabRefs = useRef>(new Map()); + + const handleTabKeyDown = useCallback( + (e: React.KeyboardEvent, current: Tab, tabs: Tab[]) => { + const idx = tabs.indexOf(current); + if (e.key === "ArrowRight" || e.key === "ArrowDown") { + e.preventDefault(); + const next = tabs[(idx + 1) % tabs.length]; + setTab(next); + tabRefs.current.get(next)?.focus(); + } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + e.preventDefault(); + const prev = tabs[(idx - 1 + tabs.length) % tabs.length]; + setTab(prev); + tabRefs.current.get(prev)?.focus(); + } else if (e.key === "Home") { + e.preventDefault(); + setTab(tabs[0]); + tabRefs.current.get(tabs[0])?.focus(); + } else if (e.key === "End") { + e.preventDefault(); + setTab(tabs[tabs.length - 1]); + tabRefs.current.get(tabs[tabs.length - 1])?.focus(); + } + }, + [], + ); const copy = useCallback(async (value: string, key: string) => { try { @@ -160,6 +187,19 @@ export function ExternalConnectModal({ info, onClose }: Props) { `MOLECULE_WORKSPACE_TOKEN=${info.auth_token}`, ); + // Build the tab list once so both the tab bar and keyboard handler + // share the same ordered array. Computed here (after all filled* vars) + // so TypeScript's block-scoping analysis can reach them. + const tabList: Tab[] = []; + if (filledUniversalMcp) tabList.push("mcp"); + tabList.push("python"); + if (filledChannel) tabList.push("claude"); + if (filledHermes) tabList.push("hermes"); + if (filledCodex) tabList.push("codex"); + if (filledOpenClaw) tabList.push("openclaw"); + if (filledKimi) tabList.push("kimi"); + tabList.push("curl", "fields"); + return ( !o && onClose()}> @@ -180,34 +220,18 @@ export function ExternalConnectModal({ info, onClose }: Props) { aria-label="Connection snippet format" className="mt-4 flex gap-1 border-b border-line" > - {(() => { - // Build the tab order dynamically. Claude Code first - // (when offered) since it's the simplest setup; Python - // SDK second (full register+heartbeat+inbound); Universal - // MCP third (any MCP-aware runtime, outbound-only); curl - // for one-shot register; Fields for raw values. - // Tab order: Universal MCP first (default, runtime- - // agnostic primitives), then runtime-specific channel/ - // SDK tabs, then curl + Fields. Each runtime tab only - // appears when the platform supplies the snippet — no - // dead "tab missing snippet" UX. - const tabs: Tab[] = []; - if (filledUniversalMcp) tabs.push("mcp"); - tabs.push("python"); - if (filledChannel) tabs.push("claude"); - if (filledHermes) tabs.push("hermes"); - if (filledCodex) tabs.push("codex"); - if (filledOpenClaw) tabs.push("openclaw"); - if (filledKimi) tabs.push("kimi"); - tabs.push("curl", "fields"); - return tabs; - })().map((t) => ( + {tabList.map((t) => (