Skip to content
Merged
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
50 changes: 50 additions & 0 deletions e2e/transactions-dialog-layering.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { expect, test, type Page } from '@playwright/test';

async function completeFreshOnboarding(page: Page) {
await expect(page.getByRole('heading', { name: 'Track money without slowing down.' })).toBeVisible();
await page.getByRole('button', { name: 'Get started' }).click();
await expect(page.getByRole('heading', { name: 'Choose the currencies you use.' })).toBeVisible();
await page.getByLabel('Cash', { exact: true }).fill('1000');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Make daily logging faster.' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'You’re ready.' })).toBeVisible();
await page.getByRole('button', { name: 'Open Ravel' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard', exact: true })).toBeVisible();
}

test('delete confirmation stays above the transaction editor and owns Escape', async ({ page }) => {
await page.goto('/app');
await completeFreshOnboarding(page);

await page.goto('/app/add');
await expect(page.getByRole('heading', { name: 'Add transaction', exact: true })).toBeVisible();
await page.getByLabel('Amount', { exact: true }).fill('10');
await page.getByLabel('What was it?').fill('Layering test');
await page.getByRole('button', { name: 'Cash', exact: true }).click();
await page.getByRole('button', { name: 'Save expense', exact: true }).click();
await expect(page.getByText('Transaction saved.', { exact: true })).toBeVisible();

await page.getByRole('link', { name: 'View transactions', exact: true }).click();
await page.getByRole('button', { name: /Layering test/ }).click();

const editDialog = page.getByRole('dialog', { name: 'Edit transaction' });
await expect(editDialog).toBeVisible();
await editDialog.getByRole('button', { name: 'Delete transaction', exact: true }).click();

const deleteDialog = page.getByRole('dialog', { name: 'Delete transaction' });
await expect(deleteDialog).toBeVisible();
await expect(editDialog).toBeVisible();

const editLayer = await editDialog.evaluate((node) =>
Number.parseInt(getComputedStyle(node.parentElement as HTMLElement).zIndex, 10)
);
const deleteLayer = await deleteDialog.evaluate((node) =>
Number.parseInt(getComputedStyle(node.parentElement as HTMLElement).zIndex, 10)
);
expect(deleteLayer).toBeGreaterThan(editLayer);

await page.keyboard.press('Escape');
await expect(deleteDialog).toBeHidden();
await expect(editDialog).toBeVisible();
});
25 changes: 17 additions & 8 deletions src/components/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useEffect, useId, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { Button } from '@/components/ui/Button';

interface ConfirmDialogProps {
Expand Down Expand Up @@ -41,16 +42,22 @@ export function ConfirmDialog({
if (!open) return;

const previousActiveElement = document.activeElement as HTMLElement | null;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
cancelRef.current?.focus();

const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !cancelDisabled) {
if (event.key === 'Escape') {
event.preventDefault();
onCancel();
event.stopPropagation();
if (!cancelDisabled) onCancel();
return;
}
if (event.key !== 'Tab') return;

// A confirmation can sit above another modal/sheet. Keep keyboard handling
// inside the top-most dialog instead of letting the parent focus trap run.
event.stopPropagation();
const focusableElements = panelRef.current?.querySelectorAll<HTMLElement>(
'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
);
Expand All @@ -67,18 +74,19 @@ export function ConfirmDialog({
}
};

document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keydown', handleKeyDown, true);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('keydown', handleKeyDown, true);
document.body.style.overflow = previousOverflow;
previousActiveElement?.focus();
};
}, [cancelDisabled, onCancel, open]);

if (!open) return null;
if (!open || typeof document === 'undefined') return null;

return (
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--overlay)] p-4"
className="fixed inset-0 z-[100] flex items-center justify-center bg-[var(--overlay)] p-4"
onClick={(event) => {
if (event.target === event.currentTarget && !cancelDisabled) onCancel();
}}
Expand Down Expand Up @@ -108,7 +116,8 @@ export function ConfirmDialog({
</Button>
</div>
</div>
</div>
</div>,
document.body
);
}

Expand Down