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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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