Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions components/ui/policy-conflict-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
* - Cancel and manually resolve
*/

import { useRef } from "react";
import { AccessPolicy } from "@/lib/api/types";
import { Button } from "./button";
import { JsonDiff } from "./json-diff";
import { useFocusTrap } from "@/lib/hooks/use-focus-trap";

interface PolicyConflictDialogProps {
/** The policy the user attempted to save */
Expand All @@ -34,10 +36,15 @@ export function PolicyConflictDialog({
onForceOverwrite,
onCancel,
}: PolicyConflictDialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
useFocusTrap(dialogRef, { isActive: true, onEscape: onCancel });

return (
<div
ref={dialogRef}
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="conflict-dialog-title"
aria-describedby="conflict-dialog-description"
>
Expand Down
7 changes: 7 additions & 0 deletions docs/accessibility-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ This document contains the results of an accessibility audit of the GuildPass in
| Bulk Action Toolbar | Missing live region for selection count, missing aria-disabled/aria-busy on buttons, missing live region for action results | Medium | ✅ Fixed |
| Scenario Selector | Missing explicit control label, missing region landmark, missing aria-busy/aria-disabled on action buttons, missing live region on status feedback | Medium | ✅ Fixed |
| SIWE Debug Panel | Missing aria-controls on toggle button, missing role="status" and aria-live="polite" on dynamic debug store updates | Medium | ✅ Fixed |
| PolicyConflictDialog | Missing aria-modal, focus trap, Escape key handler, and focus restoration | High | ✅ Fixed |

## WCAG 2.1 AA Checks

Expand Down Expand Up @@ -66,4 +67,10 @@ Automated accessibility checks can be run via `npm run test:accessibility`
**File:** components/developer/siwe-debug-panel.tsx
**Severity:** Medium

### 6. PolicyConflictDialog: Missing Modal Dialog Accessibility (Issue #297)
**Before:** The conflict dialog had `role="dialog"` but lacked `aria-modal="true"`, a focus trap, an Escape key handler, auto-focus on mount, and focus restoration on close.
**After:** Added `aria-modal="true"`, integrated the existing `useFocusTrap` hook for Tab/Shift+Tab wrapping, Escape key handling, initial focus on mount, and focus restoration when the dialog closes.
**File:** components/ui/policy-conflict-dialog.tsx
**Severity:** High


135 changes: 135 additions & 0 deletions lib/hooks/use-focus-trap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* Focus Trap Hook
*
* Traps Tab/Shift+Tab keyboard focus within a container element.
* Restores focus to the previously active element when the trap is
* torn down. Designed for modal dialogs, popovers, and side panels.
*
* Usage:
* const ref = useRef<HTMLDivElement>(null)
* useFocusTrap(ref, { isActive: open })
* return <div ref={ref}>…</div>
*/

import { useEffect, useRef } from 'react'

interface UseFocusTrapOptions {
/** Whether the trap is currently active. Default true. */
isActive?: boolean
/**
* Called when the Escape key is pressed while the trap is active.
* If omitted, Escape is ignored (no default behaviour).
*/
onEscape?: () => void
}

const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'textarea:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'area[href]',
'summary',
'iframe',
'object',
'embed',
'audio[controls]',
'video[controls]',
'[contenteditable]:not([contenteditable="false"])',
].join(', ')

function getFocusableElements(container: HTMLElement): HTMLElement[] {
const elements = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
return Array.from(elements).filter(
(el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el === container,
)
}

export function useFocusTrap(
containerRef: React.RefObject<HTMLElement | null>,
{ isActive = true, onEscape }: UseFocusTrapOptions = {},
): void {
const previousFocusRef = useRef<HTMLElement | null>(null)

useEffect(() => {
if (!isActive || !containerRef.current) return

const container = containerRef.current
// Store the currently focused element so we can restore it later
previousFocusRef.current = document.activeElement as HTMLElement | null

// Move focus into the container
const focusable = getFocusableElements(container)
if (focusable.length > 0) {
focusable[0].focus()
} else {
// If nothing is focusable, focus the container itself
container.setAttribute('tabindex', '-1')
container.focus()
}

function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && onEscape) {
e.stopPropagation()
onEscape()
return
}

if (e.key !== 'Tab') return

const focusable = getFocusableElements(container)
if (focusable.length === 0) {
e.preventDefault()
return
}

const first = focusable[0]
const last = focusable[focusable.length - 1]

if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}

document.addEventListener('keydown', handleKeyDown)

// Also catch focusout events as a Safari safety net — Safari sometimes
// processes Tab internally before our keydown handler can intercept it,
// so focus can escape the container. The focusout handler redirects
// focus back to the first focusable element.
function handleFocusOut(e: FocusEvent) {
// Only intercept if the trap is still mounted and the new target
// is outside the container.
if (
!container.contains(e.relatedTarget as Node) &&
!container.contains(e.target as Node)
) {
const focusable = getFocusableElements(container)
if (focusable.length > 0) {
focusable[0].focus()
}
}
}
container.addEventListener('focusout', handleFocusOut)

return () => {
document.removeEventListener('keydown', handleKeyDown)
container.removeEventListener('focusout', handleFocusOut)

// Restore focus to the element that had it before the trap opened
if (previousFocusRef.current && typeof previousFocusRef.current.focus === 'function') {
previousFocusRef.current.focus()
}
}
}, [isActive, onEscape, containerRef])
}
102 changes: 102 additions & 0 deletions test/policy-conflict-dialog-a11y.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import './setup-env'
import './setup-alias'
import { describe, test } from 'node:test'
import * as assert from 'node:assert/strict'
import * as React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { PolicyConflictDialog } from '../components/ui/policy-conflict-dialog'
import type { AccessPolicy } from '../lib/api/types'

const mockPolicy: AccessPolicy = {
resourceId: 'resource-1',
roles: ['admin'],
updatedAt: '2026-07-28T12:00:00Z',
}

const mockCurrentPolicy: AccessPolicy = {
resourceId: 'resource-1',
roles: ['admin'],
updatedAt: '2026-07-28T14:00:00Z',
}

describe('PolicyConflictDialog accessibility (#297)', () => {
test('renders as a dialog with aria-modal="true"', () => {
const html = renderToStaticMarkup(
React.createElement(PolicyConflictDialog, {
attemptedPolicy: mockPolicy,
currentPolicy: mockCurrentPolicy,
onReload: () => {},
onForceOverwrite: () => {},
onCancel: () => {},
}),
)

// Must have role="dialog" and aria-modal="true"
assert.match(html, /role="dialog"/)
assert.match(html, /aria-modal="true"/)

// Must have proper aria labelling
assert.match(html, /aria-labelledby="conflict-dialog-title"/)
assert.match(html, /aria-describedby="conflict-dialog-description"/)
assert.match(html, /id="conflict-dialog-title"/)
assert.match(html, /id="conflict-dialog-description"/)
})

test('renders all three action buttons', () => {
const html = renderToStaticMarkup(
React.createElement(PolicyConflictDialog, {
attemptedPolicy: mockPolicy,
currentPolicy: mockCurrentPolicy,
onReload: () => {},
onForceOverwrite: () => {},
onCancel: () => {},
}),
)

assert.match(html, />Cancel</)
assert.match(html, />Reload Latest Version</)
assert.match(html, />Force Overwrite</)
})

test('renders without currentPolicy (graceful fallback)', () => {
const html = renderToStaticMarkup(
React.createElement(PolicyConflictDialog, {
attemptedPolicy: mockPolicy,
onReload: () => {},
onForceOverwrite: () => {},
onCancel: () => {},
}),
)

// Should still have accessible dialog markup
assert.match(html, /role="dialog"/)
assert.match(html, /aria-modal="true"/)
// Should show the fallback message
assert.match(html, /Unable to load current server policy/i)
// Should still render all three buttons
assert.match(html, />Cancel</)
assert.match(html, />Reload Latest Version</)
assert.match(html, />Force Overwrite</)
})

test('dialog has a visible ref for focus trap attachment', () => {
// The outer div uses a ref for useFocusTrap. We verify it renders
// with the expected container structure.
const html = renderToStaticMarkup(
React.createElement(PolicyConflictDialog, {
attemptedPolicy: mockPolicy,
currentPolicy: mockCurrentPolicy,
onReload: () => {},
onForceOverwrite: () => {},
onCancel: () => {},
}),
)

// The dialog container wraps the card
assert.match(html, /fixed inset-0 z-50/)
assert.match(html, /role="dialog"/)
// Warning banner should be present
assert.match(html, /Warning:/)
assert.match(html, /force overwrite/i)
})
})
2 changes: 2 additions & 0 deletions test/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@
"../lib/api/index.ts",
"../components/admin-guard.tsx",
"../components/ui/bulk-action-toolbar.tsx",
"../components/ui/policy-conflict-dialog.tsx",
"../components/ui/json-diff.tsx",
"../components/developer/scenario-selector.tsx",
"../components/developer/siwe-debug-panel.tsx",
"../components/wallet/address-text.tsx",
Expand Down