fix(ui): rules drawer Esc handling — unblock board after drawer close
The T1 ModifierProfileEditor installed a window-level Esc handler that closed the modal but the RulesDrawer had no Esc handler of its own. Users hitting Esc with the drawer open (no modal) saw nothing happen; worse, with both open+modal, closing the modal left the drawer's pointer-events-blocking backdrop in place, silently breaking all board drag-interaction afterward. Fix: - Add useEffect-based Esc handler to RulesDrawer that closes it when no nested modal is active. - ModifierProfileEditor now uses capture-phase + stopImmediatePropagation so the drawer's Esc handler does NOT also fire on the same keystroke, preventing double-close. Add packages/chess/e2e/solo-smoke.spec.ts — 5 regression scenarios that would have caught this at T1 CI time. Test 4 specifically reproduces the original bug (drawer open → Esc → drag board pieces). Also queue 2 additional scenarios in the T2 plan since T2 work extends both drawer + editor further. All tests green: 1217 unit tests (94 files), 48 Playwright e2e in 1.3m.
This commit is contained in:
parent
8ae934f563
commit
728ad76a5e
5 changed files with 1262 additions and 3 deletions
90
packages/chess/e2e/solo-smoke.spec.ts
Normal file
90
packages/chess/e2e/solo-smoke.spec.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Solo-play smoke (T2 preview tests)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||
const k = localStorage.key(i);
|
||||
if (k?.startsWith('paratype-chess:')) localStorage.removeItem(k);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('play-solo button navigates to /game with fresh board', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`);
|
||||
});
|
||||
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
await page.waitForURL('**/game', { timeout: 5000 });
|
||||
await expect(page.locator('[data-square="e2"]')).toBeVisible({ timeout: 3000 });
|
||||
await expect(page.locator('[data-square="e2"] [data-piece]')).toBeVisible();
|
||||
if (errors.length > 0) throw new Error(errors.join('; '));
|
||||
});
|
||||
|
||||
test('drag pawn e2 to e4 resolves', async ({ page }) => {
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
await page.waitForURL('**/game');
|
||||
await page
|
||||
.locator('[data-square="e2"] [data-piece]')
|
||||
.dragTo(page.locator('[data-square="e4"]'));
|
||||
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({ timeout: 3000 });
|
||||
await expect(page.locator('[data-square="e2"] [data-piece]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('can play multiple moves in sequence (4-ply)', async ({ page }) => {
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
await page.waitForURL('**/game');
|
||||
|
||||
const drag = async (from: string, to: string) => {
|
||||
await page.locator(`[data-square="${from}"] [data-piece]`).dragTo(page.locator(`[data-square="${to}"]`));
|
||||
// small wait for animation / state update
|
||||
await page.waitForTimeout(100);
|
||||
};
|
||||
|
||||
await drag('e2', 'e4');
|
||||
await drag('e7', 'e5');
|
||||
await drag('g1', 'f3');
|
||||
await drag('b8', 'c6');
|
||||
|
||||
// After 4 plies: e4, e5, f3, c6 all occupied; origins empty
|
||||
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible();
|
||||
await expect(page.locator('[data-square="e5"] [data-piece]')).toBeVisible();
|
||||
await expect(page.locator('[data-square="f3"] [data-piece]')).toBeVisible();
|
||||
await expect(page.locator('[data-square="c6"] [data-piece]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('rules drawer opens without breaking board interaction', async ({ page }) => {
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
await page.waitForURL('**/game');
|
||||
await page.locator('[data-action="open-rules-drawer"]').click();
|
||||
await expect(page.getByTestId('rules-drawer')).toBeVisible();
|
||||
// Close drawer (esc or similar) and verify board still works
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
// Drag a move
|
||||
await page.locator('[data-square="e2"] [data-piece]').dragTo(page.locator('[data-square="e4"]'));
|
||||
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('no console errors on solo game start', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`);
|
||||
});
|
||||
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
await page.waitForURL('**/game');
|
||||
await page.waitForTimeout(1000); // let any async init complete
|
||||
await expect(page.locator('[data-square="e2"]')).toBeVisible();
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.log('Errors:', errors);
|
||||
throw new Error(`Unexpected errors: ${errors.slice(0, 3).join(' | ')}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -57,13 +57,22 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
|
|||
}, [isOpen]);
|
||||
|
||||
// Esc closes the modal regardless of focus.
|
||||
//
|
||||
// We use capture phase + stopImmediatePropagation so that other
|
||||
// window-level Esc handlers (e.g. the RulesDrawer behind us) do not
|
||||
// ALSO fire on the same keystroke. Without this, pressing Esc with
|
||||
// both editor+drawer open would close both, leaving neither visible
|
||||
// but the drawer's pointer-events-blocking backdrop briefly lingers.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Escape') {
|
||||
e.stopImmediatePropagation();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
|
|
|||
|
|
@ -105,6 +105,18 @@ export function RulesDrawer({
|
|||
};
|
||||
}, [open]);
|
||||
|
||||
// Esc closes the drawer. Nested modals (ModifierProfileEditor) attach
|
||||
// their own capture-phase Esc listeners with stopImmediatePropagation,
|
||||
// so this only fires when no nested modal is open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open]);
|
||||
|
||||
const toggle = (id: string, name: string) => {
|
||||
const currently = activeById.get(id);
|
||||
const next: PresetActivation[] = currently
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue