Skip to content
Closed
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
127 changes: 127 additions & 0 deletions src/__tests__/useFocusTrap.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { renderHook } from '@testing-library/react';
import { useFocusTrap } from '../hooks/useFocusTrap';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';

describe('useFocusTrap', () => {
let container: HTMLDivElement;
let triggerButton: HTMLButtonElement;
let firstBtn: HTMLButtonElement;
let lastBtn: HTMLButtonElement;
let modalRef: { current: HTMLDivElement | null };

beforeEach(() => {
// Setup DOM
document.body.innerHTML = '';

triggerButton = document.createElement('button');
triggerButton.id = 'trigger';
document.body.appendChild(triggerButton);

container = document.createElement('div');
container.id = 'modal';
container.tabIndex = -1;

firstBtn = document.createElement('button');
firstBtn.id = 'first';
lastBtn = document.createElement('button');
lastBtn.id = 'last';

container.appendChild(firstBtn);
container.appendChild(lastBtn);
document.body.appendChild(container);

modalRef = { current: container };
});

afterEach(() => {
document.body.innerHTML = '';
});

it('sets initial focus to container', () => {
triggerButton.focus();
renderHook(() => useFocusTrap(modalRef, true));
expect(document.activeElement).toBe(container);
});

it('traps focus correctly', () => {
triggerButton.focus();
renderHook(() => useFocusTrap(modalRef, true));

// Focus is on container now.
// Tab -> First
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true });
document.dispatchEvent(tabEvent);
expect(document.activeElement).toBe(firstBtn);

// Tab from Last -> First
lastBtn.focus();
document.dispatchEvent(tabEvent);
expect(document.activeElement).toBe(firstBtn);

// Shift+Tab from First -> Last
firstBtn.focus();
const shiftTabEvent = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true });
document.dispatchEvent(shiftTabEvent);
expect(document.activeElement).toBe(lastBtn);

// Shift+Tab from Container -> Last (Focus Leak Fix)
container.focus();
document.dispatchEvent(shiftTabEvent);
expect(document.activeElement).toBe(lastBtn);
});

it('restores focus on unmount', () => {
triggerButton.focus();
expect(document.activeElement).toBe(triggerButton);

const { unmount } = renderHook(() => useFocusTrap(modalRef, true));

// Focus moved to container
expect(document.activeElement).toBe(container);

unmount();

// Focus restored
expect(document.activeElement).toBe(triggerButton);
});

it('restores focus when isActive becomes false', () => {
triggerButton.focus();

const { rerender } = renderHook(
({ active }) => useFocusTrap(modalRef, active),
{ initialProps: { active: true } }
);

expect(document.activeElement).toBe(container);

rerender({ active: false });

expect(document.activeElement).toBe(triggerButton);
});

it('calls onEscape when Escape is pressed', () => {
const onEscape = vi.fn();
renderHook(() => useFocusTrap(modalRef, true, onEscape));

const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true });
document.dispatchEvent(escapeEvent);

expect(onEscape).toHaveBeenCalled();
});

it('does nothing when inactive', () => {
triggerButton.focus();
renderHook(() => useFocusTrap(modalRef, false));

// Focus stays on trigger
expect(document.activeElement).toBe(triggerButton);

// Escape should not trigger
const onEscape = vi.fn();
renderHook(() => useFocusTrap(modalRef, false, onEscape));
const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true });
document.dispatchEvent(escapeEvent);
expect(onEscape).not.toHaveBeenCalled();
});
});
9 changes: 8 additions & 1 deletion src/components/education/TutorialModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import type { WorldTutorial } from '../../types/education';
import { getTutorialByWorld } from '../../data/educationContent';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import './TutorialModal.css';

interface TutorialModalProps {
Expand Down Expand Up @@ -32,6 +33,8 @@ export function TutorialModal({
const [showCode, setShowCode] = useState(false);
const [typedCode, setTypedCode] = useState('');

const modalRef = useRef<HTMLDivElement>(null);

// Carrega tutorial do mundo
useEffect(() => {
if (worldId) {
Expand Down Expand Up @@ -105,6 +108,8 @@ export function TutorialModal({
}
}, [forceWatch, onClose]);

useFocusTrap(modalRef, isOpen, handleSkip);

if (!isOpen || !tutorial) return null;

const step = tutorial.steps[currentStep];
Expand All @@ -119,6 +124,8 @@ export function TutorialModal({
role="dialog"
aria-modal="true"
aria-labelledby="tutorial-title"
ref={modalRef}
tabIndex={-1}
>
{/* Header */}
<div className="tutorial-modal__header">
Expand Down
83 changes: 83 additions & 0 deletions src/hooks/useFocusTrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useEffect, RefObject } from 'react';

Check failure on line 1 in src/hooks/useFocusTrap.ts

View workflow job for this annotation

GitHub Actions / 🔨 Build & Lint

'RefObject' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.

/**
* Hook to trap focus inside a modal and handle Escape key.
* @param ref Reference to the modal container
* @param isActive Whether the modal is open/active
* @param onEscape Callback for Escape key
* @param initialFocusRef Optional reference to element to focus on mount. If not provided, will try to focus the container.
*/
export function useFocusTrap(
ref: RefObject<HTMLElement | null>,
isActive: boolean,
onEscape?: () => void,
initialFocusRef?: RefObject<HTMLElement | null>
) {
useEffect(() => {
if (!isActive) return;

const previousElement = document.activeElement as HTMLElement;

// Initial focus
if (initialFocusRef?.current) {
initialFocusRef.current.focus();
} else if (ref.current) {
ref.current.focus();
}

const handleKeyDown = (e: KeyboardEvent) => {
// Handle Escape
if (e.key === 'Escape' && onEscape) {
e.preventDefault();
onEscape();
return;
}

// Handle Tab (Focus Trap)
if (e.key === 'Tab' && ref.current) {
const focusableElements = ref.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);

if (focusableElements.length === 0) {
e.preventDefault();
return;
}

const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const activeElement = document.activeElement;

if (e.shiftKey) {
// Shift + Tab
// If focus is on first element OR the container itself (initial state), wrap to last
if (activeElement === firstElement || activeElement === ref.current) {
e.preventDefault();
lastElement.focus();
}
} else {
// Tab
if (activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
// If focus is on container, ensure we go to first element
else if (activeElement === ref.current) {
e.preventDefault();
firstElement.focus();
}
}
}
};

document.addEventListener('keydown', handleKeyDown);

return () => {
document.removeEventListener('keydown', handleKeyDown);
// Restore focus
if (previousElement && document.body.contains(previousElement)) {
previousElement.focus();
}
};
}, [isActive, onEscape, ref, initialFocusRef]);
}
Loading