|
| 1 | +const { test, expect } = require('@playwright/test'); |
| 2 | + |
| 3 | +test.describe('Modal Lifecycle & Focus Trapping', () => { |
| 4 | + test('should open modal, trap keyboard focus, close modal, and restore focus to trigger button', async ({ page }) => { |
| 5 | + await page.goto('/'); |
| 6 | + |
| 7 | + // Scroll to projects to show cards |
| 8 | + const exploreBtn = page.locator('#exploreBtn'); |
| 9 | + await exploreBtn.click(); |
| 10 | + |
| 11 | + // Wait for sidebar to be active |
| 12 | + await expect(page.locator('body')).toHaveClass(/sidebar-active/); |
| 13 | + |
| 14 | + // Get the first project card's "Try It" button |
| 15 | + const firstCardPlayBtn = page.locator('.project-card .btn-play').first(); |
| 16 | + await expect(firstCardPlayBtn).toBeVisible(); |
| 17 | + |
| 18 | + // Focus and click the play button |
| 19 | + await firstCardPlayBtn.focus(); |
| 20 | + await firstCardPlayBtn.click(); |
| 21 | + |
| 22 | + const modal = page.locator('#projectModal'); |
| 23 | + // Verify modal has active class |
| 24 | + await expect(modal).toHaveClass(/active/); |
| 25 | + |
| 26 | + // Selector for focusable elements |
| 27 | + const focusableSelector = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; |
| 28 | + |
| 29 | + // Get focusable element tags/ids/classes inside modal to compare |
| 30 | + const focusablesInModal = await modal.evaluate((modalEl, sel) => { |
| 31 | + return Array.from(modalEl.querySelectorAll(sel)) |
| 32 | + .filter(el => !el.closest('[aria-hidden="true"]') && !el.classList.contains("visually-hidden")) |
| 33 | + .map(el => el.tagName + (el.id ? '#' + el.id : '') + (el.className ? '.' + el.className.split(' ').join('.') : '')); |
| 34 | + }, focusableSelector); |
| 35 | + |
| 36 | + expect(focusablesInModal.length).toBeGreaterThan(0); |
| 37 | + |
| 38 | + // 1. Manually focus the first focusable element inside the modal |
| 39 | + await modal.evaluate((modalEl, sel) => { |
| 40 | + const focusables = Array.from(modalEl.querySelectorAll(sel)) |
| 41 | + .filter(el => !el.closest('[aria-hidden="true"]') && !el.classList.contains("visually-hidden")); |
| 42 | + focusables[0].focus(); |
| 43 | + }, focusableSelector); |
| 44 | + |
| 45 | + // 2. Press Shift+Tab. Focus should wrap around to the last focusable element in the modal. |
| 46 | + await page.keyboard.press('Shift+Tab'); |
| 47 | + |
| 48 | + let activeTag = await page.evaluate(() => { |
| 49 | + const el = document.activeElement; |
| 50 | + return el.tagName + (el.id ? '#' + el.id : '') + (el.className ? '.' + el.className.split(' ').join('.') : ''); |
| 51 | + }); |
| 52 | + const lastTagName = focusablesInModal[focusablesInModal.length - 1]; |
| 53 | + expect(activeTag).toBe(lastTagName); |
| 54 | + |
| 55 | + // 3. Press Tab. Focus should wrap back to the first focusable element in the modal. |
| 56 | + await page.keyboard.press('Tab'); |
| 57 | + |
| 58 | + activeTag = await page.evaluate(() => { |
| 59 | + const el = document.activeElement; |
| 60 | + return el.tagName + (el.id ? '#' + el.id : '') + (el.className ? '.' + el.className.split(' ').join('.') : ''); |
| 61 | + }); |
| 62 | + const firstTagName = focusablesInModal[0]; |
| 63 | + expect(activeTag).toBe(firstTagName); |
| 64 | + |
| 65 | + // 4. Close the modal by pressing Escape |
| 66 | + await page.keyboard.press('Escape'); |
| 67 | + |
| 68 | + // Verify modal is closed (no longer has active class) |
| 69 | + await expect(modal).not.toHaveClass(/active/); |
| 70 | + |
| 71 | + // Verify focus is restored to the original play button |
| 72 | + const isPlayBtnFocused = await firstCardPlayBtn.evaluate(el => el === document.activeElement); |
| 73 | + expect(isPlayBtnFocused).toBe(true); |
| 74 | + }); |
| 75 | +}); |
0 commit comments