Skip to content

[Feature Request] Add keyboard accessibility (focus management, Escape key, ARIA roles) to Modal and Dropdown components #264

Description

@prince-pokharna

Summary

Sketchbook UI's Modal and Dropdown components currently lack the keyboard accessibility behaviour required by WCAG 2.1 and the WAI-ARIA Authoring Practices. Specifically: the Modal does not trap focus inside the dialog when open, does not close on Escape key, and does not restore focus to the trigger element on close. The Dropdown similarly does not support arrow-key navigation between options. For a published npm library used in real projects, these gaps make the components unusable for keyboard-only users and inaccessible to screen readers.

Problem

  • Modal: Opening the modal does not move focus inside it. Users navigating by keyboard can Tab through elements behind the modal overlay. Pressing Escape does not close the modal. When the modal closes, focus is not returned to the element that triggered it. The dialog element lacks role="dialog" and aria-modal="true".
  • Dropdown: Options are not navigable with ArrowUp/ArrowDown keys. Pressing Enter or Space does not select the focused option. The dropdown trigger lacks aria-expanded and aria-haspopup attributes.
  • These are not cosmetic issues — they are functional blockers for keyboard-only users and fail WCAG 2.1 Success Criteria 2.1.1 (Keyboard) and 2.1.2 (No Keyboard Trap).

Impact

  • The library is unusable by keyboard-only users for any workflow involving Modal or Dropdown.
  • Screen readers cannot correctly announce these components to visually impaired users.
  • Projects using sketchbook-ui that require accessibility compliance (government, education, enterprise) cannot adopt these components.
  • The hand-drawn aesthetic is unique and attractive — it deserves to be fully accessible so it can be used in production without compromise.

Proposed Solution

Modal — focus trap and keyboard handling:

// Inside Modal component
import { useEffect, useRef } from "react";

const FOCUSABLE_SELECTORS =
  'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';

export function Modal({ isOpen, onClose, children, ...props }) {
  const modalRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<Element | null>(null);

  useEffect(() => {
    if (isOpen) {
      triggerRef.current = document.activeElement;
      const firstFocusable = modalRef.current?.querySelector<HTMLElement>(FOCUSABLE_SELECTORS);
      firstFocusable?.focus();
    } else {
      (triggerRef.current as HTMLElement)?.focus();
    }
  }, [isOpen]);

  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (!isOpen) return;
      if (e.key === "Escape") onClose();
      if (e.key === "Tab") {
        // focus trap logic — cycle within modal
        const focusableEls = Array.from(
          modalRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS) ?? []
        );
        const first = focusableEls[0];
        const last = focusableEls[focusableEls.length - 1];
        if (e.shiftKey ? document.activeElement === first : document.activeElement === last) {
          e.preventDefault();
          (e.shiftKey ? last : first).focus();
        }
      }
    };
    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [isOpen, onClose]);

  return (
    <div
      ref={modalRef}
      role="dialog"
      aria-modal="true"
      aria-label={props["aria-label"] ?? "Dialog"}
      // ... rest of props
    >
      {children}
    </div>
  );
}

Dropdown — keyboard navigation:

// Arrow key navigation + Enter/Space selection
const handleKeyDown = (e: React.KeyboardEvent) => {
  switch (e.key) {
    case "ArrowDown": setHighlightedIndex(i => Math.min(i + 1, options.length - 1)); break;
    case "ArrowUp":   setHighlightedIndex(i => Math.max(i - 1, 0)); break;
    case "Enter":
    case " ":         onSelect(options[highlightedIndex]); setOpen(false); break;
    case "Escape":    setOpen(false); break;
  }
};

Scope of this PR:

  • Modal: focus trap, Escape to close, focus restoration, role="dialog", aria-modal="true".
  • Dropdown: arrow key navigation, Enter/Space to select, aria-expanded, aria-haspopup, aria-activedescendant.
  • Unit tests for each accessibility behaviour using @testing-library/user-event.
  • Storybook story updates showing keyboard interaction in the docs.

Could you please assign this issue to me?

Labels: accessibility, bug, enhancement, GSSoC 2026

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions