fix(canvas/mobile): ARIA radio role + keyboard nav for MobileComms filter buttons #1441

Open
core-uiux wants to merge 8 commits from design/mobile-comms-a11y into main
10 changed files with 179 additions and 40 deletions
+13
View File
@@ -287,4 +287,17 @@ body {
outline: 2px solid var(--accent, #3b5bdb);
outline-offset: 2px;
}
/* Global focus-visible fallback for Tailwind buttons (WCAG 2.4.7).
Specific component rules (e.g. .settings-panel__close:focus-visible)
override this via higher specificity. */
button:focus-visible {
outline: 2px solid var(--accent, #3b5bdb);
outline-offset: 2px;
}
a:focus-visible {
outline: 2px solid var(--accent, #3b5bdb);
outline-offset: 2px;
}
}
@@ -205,6 +205,7 @@ export function MobileCanvas({
type="button"
onClick={resetView}
aria-label="Reset zoom"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={{
position: "absolute",
right: 14,
@@ -272,6 +273,8 @@ export function MobileCanvas({
key={l.agent.id}
type="button"
onClick={() => onOpen(l.agent.id)}
aria-label={`Open ${l.agent.name}`}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={{
position: "absolute",
left: `${l.x}%`,
@@ -376,6 +379,7 @@ export function MobileCanvas({
type="button"
onClick={onSpawn}
aria-label="Spawn new agent"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2"
style={{
position: "absolute",
right: 24,
+65 -30
View File
@@ -339,6 +339,7 @@ export function MobileChat({
type="button"
onClick={onBack}
aria-label="Back"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={{
width: 36,
height: 36,
@@ -385,6 +386,7 @@ export function MobileChat({
<button
type="button"
aria-label="More"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={{
width: 36,
height: 36,
@@ -402,35 +404,62 @@ export function MobileChat({
</button>
</div>
{/* Sub-tabs */}
<div style={{ display: "flex", gap: 18, marginTop: 12, paddingLeft: 4 }}>
{(
[
{ id: "my", label: "My Chat" },
{ id: "a2a", label: "Agent Comms" },
] as const
).map((t) => {
const on = tab === t.id;
return (
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
style={{
padding: "4px 0 8px",
border: "none",
background: "transparent",
fontSize: 13.5,
cursor: "pointer",
color: on ? p.text : p.text3,
fontWeight: on ? 600 : 500,
borderBottom: on ? `2px solid ${p.accent}` : "2px solid transparent",
}}
>
{t.label}
</button>
);
})}
</div>
{(
[
{ id: "my", label: "My Chat" },
{ id: "a2a", label: "Agent Comms" },
] as const
).map((t) => {
const on = tab === t.id;
return (
<button
key={t.id}
role="tab"
type="button"
tabIndex={on ? 0 : -1}
aria-selected={on}
onClick={() => setTab(t.id)}
onKeyDown={(e) => {
const tabs = ["my", "a2a"] as const;
const idx = tabs.indexOf(t.id);
let nextIdx: number | null = null;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
nextIdx = (idx + 1) % tabs.length;
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
nextIdx = (idx - 1 + tabs.length) % tabs.length;
} else if (e.key === "Home") {
nextIdx = 0;
} else if (e.key === "End") {
nextIdx = tabs.length - 1;
}
if (nextIdx !== null) {
e.preventDefault();
setTab(tabs[nextIdx]!);
setTimeout(() => {
const btns = document.querySelectorAll('[role="tab"]');
(btns[nextIdx!] as HTMLButtonElement | null)?.focus();
}, 0);
}
}}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={{
padding: "4px 0 8px",
marginTop: 12,
marginLeft: 4,
marginRight: 14,
border: "none",
background: "transparent",
fontSize: 13.5,
cursor: "pointer",
color: on ? p.text : p.text3,
fontWeight: on ? 600 : 500,
borderBottom: on ? `2px solid ${p.accent}` : "2px solid transparent",
}}
>
{t.label}
</button>
);
})}
</div>
{/* Messages */}
@@ -478,6 +507,7 @@ export function MobileChat({
onClick={() => {
loadInitial();
}}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={{
padding: "6px 14px",
borderRadius: 14,
@@ -619,6 +649,7 @@ export function MobileChat({
type="button"
onClick={() => removePendingFile(i)}
aria-label={`Remove ${f.name}`}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={{
border: "none",
background: "transparent",
@@ -659,6 +690,7 @@ export function MobileChat({
onClick={() => fileInputRef.current?.click()}
disabled={!reachable || sending || uploading}
aria-label="Attach"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={{
width: 32,
height: 32,
@@ -703,7 +735,9 @@ export function MobileChat({
border: "none",
outline: "none",
background: "transparent",
fontSize: 14.5,
// 16px minimum prevents iOS from zooming the page when the
// textarea receives focus (iOS triggers zoom for font-size < 16).
fontSize: 16,
lineHeight: 1.4,
color: p.text,
padding: "6px 0",
@@ -719,6 +753,7 @@ export function MobileChat({
onClick={send}
disabled={(!draft.trim() && pendingFiles.length === 0) || !reachable || sending || uploading}
aria-label="Send"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={{
width: 36,
height: 36,
+32 -8
View File
@@ -205,7 +205,11 @@ export function MobileComms({ dark }: { dark: boolean }) {
</p>
</div>
<div style={{ display: "flex", gap: 6, padding: "12px 16px 8px" }}>
<div
role="radiogroup"
aria-label="Filter communications"
style={{ display: "flex", gap: 6, padding: "12px 16px 8px" }}
>
{(
[
{ id: "all", label: "All", n: items.length },
@@ -216,8 +220,34 @@ export function MobileComms({ dark }: { dark: boolean }) {
return (
<button
key={o.id}
role="radio"
type="button"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => setFilter(o.id)}
onKeyDown={(e) => {
const filters = ["all", "errors"] as const;
const idx = filters.indexOf(o.id as "all" | "errors");
let next: number | null = null;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
next = (idx + 1) % filters.length;
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
next = (idx - 1 + filters.length) % filters.length;
} else if (e.key === "Home") {
next = 0;
} else if (e.key === "End") {
next = filters.length - 1;
}
if (next !== null) {
e.preventDefault();
setFilter(filters[next]!);
setTimeout(() => {
const btns = document.querySelectorAll('[role="radio"]');
(btns[next!] as HTMLButtonElement | null)?.focus();
}, 0);
}
}}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={{
display: "inline-flex",
alignItems: "center",
@@ -233,13 +263,7 @@ export function MobileComms({ dark }: { dark: boolean }) {
}}
>
{o.label}
<span
style={{
fontSize: 10.5,
opacity: 0.7,
fontFamily: MOBILE_FONT_MONO,
}}
>
<span aria-hidden="true" style={{ fontSize: 10.5, opacity: 0.7, fontFamily: MOBILE_FONT_MONO }}>
{o.n}
</span>
</button>
+30 -1
View File
@@ -83,11 +83,12 @@ export function MobileDetail({
type="button"
onClick={onBack}
aria-label="Back"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={iconButtonStyle(p, dark)}
>
{Icons.back({ size: 18 })}
</button>
<button type="button" aria-label="More" style={iconButtonStyle(p, dark)}>
<button type="button" aria-label="More" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" style={iconButtonStyle(p, dark)}>
{Icons.more({ size: 18 })}
</button>
</div>
@@ -168,6 +169,8 @@ export function MobileDetail({
{/* Tabs */}
<div
role="tablist"
aria-label="Agent detail sections"
style={{
display: "flex",
gap: 4,
@@ -181,8 +184,33 @@ export function MobileDetail({
return (
<button
key={t.id}
role="tab"
type="button"
tabIndex={on ? 0 : -1}
aria-selected={on}
onClick={() => setTab(t.id)}
onKeyDown={(e) => {
const idx = TABS.findIndex((x) => x.id === t.id);
let nextIdx: number | null = null;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
nextIdx = (idx + 1) % TABS.length;
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
nextIdx = (idx - 1 + TABS.length) % TABS.length;
} else if (e.key === "Home") {
nextIdx = 0;
} else if (e.key === "End") {
nextIdx = TABS.length - 1;
}
if (nextIdx !== null) {
e.preventDefault();
setTab(TABS[nextIdx]!.id);
setTimeout(() => {
const btns = document.querySelectorAll('[role="tab"]');
(btns[nextIdx!] as HTMLButtonElement | null)?.focus();
}, 0);
}
}}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={{
padding: "8px 14px",
borderRadius: 999,
@@ -215,6 +243,7 @@ export function MobileDetail({
type="button"
onClick={onChat}
data-testid="mobile-chat-cta"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2"
style={{
width: "100%",
height: 52,
@@ -183,6 +183,7 @@ export function MobileHome({
type="button"
onClick={onSpawn}
aria-label="Spawn new agent"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2"
style={{
position: "absolute",
right: 24,
@@ -83,6 +83,7 @@ export function MobileMe({
type="button"
onClick={() => setAccent(c)}
aria-label={`Set accent ${c}`}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={{
width: 36,
height: 36,
@@ -173,6 +174,7 @@ function SegmentedRow({
key={o.id}
type="button"
onClick={() => onChange(o.id)}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={{
flex: 1,
padding: "10px 8px",
+5 -1
View File
@@ -148,6 +148,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
type="button"
onClick={onClose}
aria-label="Close"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={{
width: 32,
height: 32,
@@ -214,6 +215,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
setTplId(t.id);
setTier(tCode);
}}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={{
background: on
? dark
@@ -286,7 +288,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
justifyContent: "center",
}}
>
{Icons.check({ size: 10, sw: 2.5 })}
<span aria-hidden="true">{Icons.check({ size: 10, sw: 2.5 })}</span>
</span>
)}
</button>
@@ -330,6 +332,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
key={t}
type="button"
onClick={() => setTier(t)}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={{
flex: 1,
padding: "10px 8px",
@@ -377,6 +380,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
type="button"
onClick={handleSpawn}
disabled={busy || !tplId || templates.length === 0}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2"
style={{
width: "100%",
height: 52,
@@ -291,6 +291,7 @@ export function AgentCard({
data-testid="workspace-card"
aria-label={`${agent.name}, status: ${agent.status}, tier ${agent.tier}${agent.remote ? ", remote" : ""}`}
onClick={onClick}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={{
display: "block",
width: "100%",
@@ -444,6 +445,7 @@ export function FilterChips({
type="button"
aria-checked={on}
onClick={() => onChange(o.id)}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={{
display: "inline-flex",
alignItems: "center",
+25
View File
@@ -411,6 +411,11 @@
color: #f4f4f5;
}
.secrets-tab__add-btn:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
/* ── Shared UI ─────────────────────────────────────── */
.key-value-field {
@@ -585,6 +590,11 @@
background: #1e40af;
}
.secrets-tab__refresh-btn:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
.secrets-tab__no-results {
text-align: center;
padding: 24px;
@@ -601,6 +611,11 @@
font-size: 14px;
}
.secrets-tab__clear-search:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
/* ── Delete confirmation dialog ────────────────────── */
.delete-dialog__overlay {
@@ -661,6 +676,16 @@
.delete-dialog__confirm-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.delete-dialog__confirm-btn:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
.delete-dialog__cancel-btn:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
/* ── Unsaved changes guard ─────────────────────────── */
.guard-dialog__overlay {