Skip to content

[a11y] WalletModal is missing required ARIA dialog attributes, keyboard trap, and close semantics #7

Description

@tanishk-kh-07

Branch: landing-dashboard-page
File: components/wallet/WalletModal.tsx
Affected Pages: app/page.tsx (landing), app/dashboard/page.tsx (dashboard)
WCAG Reference: WCAG 2.1 SC 4.1.2 — Name, Role, Value (Level AA)


Summary

The WalletModal component renders a custom overlay modal but does not expose the semantics required for assistive technologies (screen readers, switch controls). It is currently implemented as a plain <div> with no ARIA dialog role, no focus management, and no keyboard escape handling — making it effectively invisible and unusable for keyboard-only and screen reader users.


Steps to Reproduce

  1. Checkout the landing-dashboard-page branch and run the dev server (npm run dev).
  2. Open the page in a browser with a screen reader active (e.g. NVDA + Firefox, or macOS VoiceOver + Safari).
  3. Click "Connect Wallet" in the Navbar or the Hero Section to open the modal.
  4. Observe that:
    • The screen reader does not announce the dialog or its label.
    • Focus is not moved into the modal; it remains behind the backdrop.
    • Pressing Escape does not close the modal.
    • Focus is not trapped — the reader can tab to elements hidden behind the backdrop.
    • The close button () has no accessible label.

Root Cause

1. Missing role="dialog" and aria-modal="true" on the modal card (Line 33)

The modal card <div> is a plain div. Without role="dialog", assistive technologies do not know a modal context has been opened.

2. Missing aria-labelledby linking the modal to its visible title (Lines 33 & 36)

The <h3>Connect a Wallet</h3> (line 36) exists in the DOM but is never associated with the dialog container, so screen readers cannot announce the modal's purpose.

3. Missing aria-label on the close button (Lines 37–43)

The close button uses the Unicode character as its only content. Screen readers will either announce it as "multiplication sign" or skip it entirely.

4. No focus management on open/close

When the modal opens, focus stays on the triggering button behind the backdrop. WCAG 2.4.3 requires that focus move into the dialog. When the modal closes, focus must return to the trigger element.

5. No Escape key handler

The WAI-ARIA Authoring Practices Guide (APG) for the Dialog Pattern specifies that pressing Escape must close a modal dialog. There is currently no keydown handler.

6. No focus trap

While the backdrop onClick prevents interaction, focus can still leave the modal via Tab/Shift+Tab, allowing screen reader users to interact with content behind the overlay.


Proposed Fix

The following diff illustrates the minimum changes needed to achieve WCAG 2.1 AA compliance for dialog semantics. A useRef on the modal container, combined with a useEffect, handles focus-on-open, Escape-key-close, and focus-trap.

'use client';

import React, { useEffect, useRef } from 'react';
import { useWallet } from '@/context/WalletContext';

export default function WalletModal() {
  const {
    walletModalOpen,
    setWalletModalOpen,
    isConnecting,
    connectingWallet,
    connectWallet,
  } = useWallet();

  const modalRef = useRef<HTMLDivElement>(null);
  const titleId = 'wallet-modal-title';

  // --- FIX 1: Focus management + Escape key handler ---
  useEffect(() => {
    if (!walletModalOpen) return;

    // Move focus into the modal on open
    modalRef.current?.focus();

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape' && !isConnecting) {
        setWalletModalOpen(false);
      }

      // Basic focus trap
      if (e.key === 'Tab' && modalRef.current) {
        const focusable = modalRef.current.querySelectorAll<HTMLElement>(
          'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        const first = focusable[0];
        const last = focusable[focusable.length - 1];

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

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [walletModalOpen, isConnecting, setWalletModalOpen]);

  if (!walletModalOpen) return null;

  const wallets = [
    { name: 'MetaMask', icon: '🦊', desc: 'Popular EVM browser extension' },
    { name: 'Coinbase Wallet', icon: '🛡️', desc: 'Secure self-custody wallet' },
    { name: 'Rainbow', icon: '🌈', desc: 'Fun and easy Ethereum wallet' },
    { name: 'WalletConnect', icon: '🔌', desc: 'Scan with mobile wallet' },
  ];

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
      {/* Backdrop */}
      <div
        className="absolute inset-0 bg-black/40 backdrop-blur-sm"
        onClick={() => !isConnecting && setWalletModalOpen(false)}
        aria-hidden="true"   // FIX 2: hide backdrop from the accessibility tree
      />

      {/* FIX 3: role="dialog", aria-modal, aria-labelledby, tabIndex for focus target */}
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        tabIndex={-1}
        className="relative w-full max-w-md overflow-hidden rounded-3xl border border-neutral-200 bg-white p-6 text-black shadow-2xl transition-all duration-300 outline-none"
      >
        {/* Modal Header */}
        <div className="mb-6 flex items-center justify-between">
          {/* FIX 4: give the title a stable id to satisfy aria-labelledby */}
          <h3 id={titleId} className="text-xl font-bold tracking-tight">
            Connect a Wallet
          </h3>

          {/* FIX 5: aria-label on the icon-only close button */}
          <button
            onClick={() => !isConnecting && setWalletModalOpen(false)}
            disabled={isConnecting}
            aria-label="Close wallet connection modal"
            className="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-100 text-neutral-500 hover:bg-neutral-200 hover:text-black transition-colors disabled:opacity-50"
          >
            <span aria-hidden="true"></span>
          </button>
        </div>

        {/* ... rest of the component unchanged ... */}
      </div>
    </div>
  );
}

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions