Files
TREK/client/src/components/shared/ConfirmDialog.test.tsx
T
Maurice d06168d94a feat(native): the app answers a finger like a native app, not like a web page
After a tap, WebKit and Chrome keep :hover on what was tapped and send a
made-up mouseover, so buttons and rows stayed tinted until the next tap.
While a finger drives the app <html> carries data-touch-input: a PostCSS
step guards every :hover rule with it (no specificity change), and the
made-up mouseover and mouseout stop before any handler. A trackpad or mouse
clears the mark and gets hover back; a browser never sets it.

A control without a press of its own dims the moment a finger lands on it,
after a 60 ms wait so a scroll does not flash rows, and only the innermost
pressed control. A focus: ring no longer stays round a tapped button.

Fields get a fitting return key ("done", "go" in a form, "search" on the
search fields), and addresses, codes and numbers are no longer capitalised
or corrected. Ticking off packing items and tasks, a picker chip, a select
option, a picked date and dragging a plan row now give the haptics a native
list gives.

Confirmations ask through the system where the binary has the new
@capacitor/action-sheet and @capacitor/dialog plugins: a sheet with the
action in red when it destroys something, an alert otherwise, also for
"discard changes" in a sheet. An older app, a browser and a question with a
list under it keep the HTML dialog, as does a system that could not ask.

iOS no longer lifts a page preview on a long press on a link.
2026-11-09 08:54:51 +01:00

236 lines
11 KiB
TypeScript

import { render, screen, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/react';
import ConfirmDialog from './ConfirmDialog';
import Modal from './Modal';
import { askNatively, canAskNatively } from '../../native/nativeDialogs';
vi.mock('../../native/nativeDialogs', () => ({ canAskNatively: vi.fn(() => false), askNatively: vi.fn() }));
describe('ConfirmDialog', () => {
const onClose = vi.fn();
const onConfirm = vi.fn();
beforeEach(() => {
onClose.mockClear();
onConfirm.mockClear();
});
it('FE-COMP-CONFIRM-001: does not render when isOpen is false', () => {
render(
<ConfirmDialog isOpen={false} onClose={onClose} onConfirm={onConfirm} message="Are you sure?" />
);
expect(screen.queryByText('Are you sure?')).toBeNull();
});
it('FE-COMP-CONFIRM-002: renders with default title "Confirm" and message', () => {
render(
<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} message="Are you sure?" />
);
expect(screen.getByText('Confirm')).toBeTruthy();
expect(screen.getByText('Are you sure?')).toBeTruthy();
});
it('FE-COMP-CONFIRM-003: renders custom title and message', () => {
render(
<ConfirmDialog
isOpen={true}
onClose={onClose}
onConfirm={onConfirm}
title="Remove item"
message="This cannot be undone."
/>
);
expect(screen.getByText('Remove item')).toBeTruthy();
expect(screen.getByText('This cannot be undone.')).toBeTruthy();
});
it('FE-COMP-CONFIRM-004: Cancel button calls onClose', async () => {
const user = userEvent.setup();
render(<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} />);
await user.click(screen.getByRole('button', { name: /cancel/i }));
expect(onClose).toHaveBeenCalledOnce();
expect(onConfirm).not.toHaveBeenCalled();
});
it('FE-COMP-CONFIRM-005: Confirm button calls onConfirm and onClose', async () => {
const user = userEvent.setup();
render(<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} />);
await user.click(screen.getByRole('button', { name: /delete/i }));
expect(onConfirm).toHaveBeenCalledOnce();
expect(onClose).toHaveBeenCalledOnce();
});
it('FE-COMP-CONFIRM-006: custom button labels render correctly', () => {
render(
<ConfirmDialog
isOpen={true}
onClose={onClose}
onConfirm={onConfirm}
confirmLabel="Yes, remove"
cancelLabel="Go back"
/>
);
expect(screen.getByRole('button', { name: 'Yes, remove' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Go back' })).toBeTruthy();
});
it('FE-COMP-CONFIRM-007: Escape key calls onClose', () => {
render(<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} />);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledOnce();
});
it('FE-COMP-CONFIRM-009: a rejecting async onConfirm does not escape as an unhandled rejection', async () => {
const unhandled = vi.fn();
process.on('unhandledRejection', unhandled);
const failing = vi.fn(() => Promise.reject(new Error('delete failed')));
render(<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={failing} />);
fireEvent.click(screen.getByRole('button', { name: /delete/i }));
await new Promise(resolve => setTimeout(resolve, 0));
expect(failing).toHaveBeenCalledOnce();
expect(onClose).toHaveBeenCalledOnce();
expect(unhandled).not.toHaveBeenCalled();
process.off('unhandledRejection', unhandled);
});
it('FE-COMP-CONFIRM-010: extra content renders under the message, and the card widens for it', () => {
render(
<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} message="Delete it?">
<ul aria-label="consequences"><li>Stay at Harbour Hotel</li></ul>
</ConfirmDialog>
);
const list = screen.getByRole('list', { name: 'consequences' });
expect(list).toHaveTextContent('Stay at Harbour Hotel');
expect(screen.getByText('Delete it?').compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(document.querySelector('.max-w-md')).not.toBeNull();
});
it('FE-COMP-CONFIRM-011: Escape takes back the question only, not the dialog it was asked from', () => {
const closeModal = vi.fn();
render(
<>
<Modal isOpen={true} onClose={closeModal} title="Reorder days">rows</Modal>
<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} message="Delete it?" />
</>
);
fireEvent.keyDown(document.body, { key: 'Escape' });
expect(onClose).toHaveBeenCalledOnce();
expect(closeModal).not.toHaveBeenCalled();
// Other keys pass through untouched.
fireEvent.keyDown(document.body, { key: 'Enter' });
expect(onClose).toHaveBeenCalledOnce();
});
it('FE-COMP-CONFIRM-012: the danger look comes from the danger tokens, the plain one from the accent', () => {
const { unmount } = render(<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} />);
expect(screen.getByRole('button', { name: /delete/i })).toHaveClass('bg-danger');
expect(document.querySelector('.bg-danger-soft')).not.toBeNull();
unmount();
render(<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} danger={false} confirmLabel="Keep" />);
expect(screen.getByRole('button', { name: 'Keep' })).toHaveClass('bg-accent', 'text-accent-text');
expect(document.querySelector('.bg-danger-soft')).toBeNull();
});
it('FE-COMP-CONFIRM-013: a long list scrolls inside the card, and the buttons stay outside it, always in reach', () => {
render(
<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} message="Delete it?">
<ul aria-label="consequences">{Array.from({ length: 40 }, (_, i) => <li key={i}>Row {i}</li>)}</ul>
</ConfirmDialog>
);
const scroller = screen.getByRole('list', { name: 'consequences' }).closest('.overflow-y-auto') as HTMLElement;
expect(scroller).not.toBeNull();
expect(scroller.parentElement).toHaveClass('flex-col');
expect(scroller.parentElement!.className).toMatch(/max-h-\[/);
const confirm = screen.getByRole('button', { name: /delete/i });
expect(scroller.contains(confirm)).toBe(false);
expect(confirm.parentElement).toHaveClass('flex-none');
});
it('FE-COMP-CONFIRM-008: clicking backdrop calls onClose', async () => {
const user = userEvent.setup();
render(<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} message="msg" />);
// The outermost fixed div is the backdrop — click outside the card
const backdrop = document.querySelector('.fixed') as HTMLElement;
// fireEvent click on the backdrop element directly
fireEvent.click(backdrop);
expect(onClose).toHaveBeenCalledOnce();
});
describe('on a phone', () => {
const desktopWidth = window.innerWidth;
beforeEach(() => { Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 390 }); });
afterEach(() => { Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: desktopWidth }); });
it('FE-COMP-CONFIRM-014: the phone card answers like the desktop one, and a tap on it does not close it', async () => {
const user = userEvent.setup();
render(<ConfirmDialog isOpen onClose={onClose} onConfirm={onConfirm} title="Delete note?" message="It is gone for good." confirmLabel="Remove" />);
await user.click(screen.getByText('It is gone for good.'));
expect(onClose).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Remove' }));
expect(onConfirm).toHaveBeenCalledOnce();
expect(onClose).toHaveBeenCalledOnce();
});
it('FE-COMP-CONFIRM-015: the phone card without danger uses the accent and the default labels', async () => {
const user = userEvent.setup();
render(<ConfirmDialog isOpen onClose={onClose} onConfirm={onConfirm} message="Leave?" danger={false}><p>More</p></ConfirmDialog>);
expect(screen.getByText('Confirm')).toBeInTheDocument();
expect(screen.getByText('More')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Delete' })).toHaveClass('bg-accent');
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalledOnce();
expect(onConfirm).not.toHaveBeenCalled();
});
});
describe('in the app, the system asks', () => {
afterEach(() => {
vi.mocked(canAskNatively).mockReturnValue(false);
vi.mocked(askNatively).mockReset();
});
it('FE-COMP-CONFIRM-NATIVE-001: asks once through the system, with the action in red, and confirms on yes', async () => {
vi.mocked(canAskNatively).mockReturnValue(true);
vi.mocked(askNatively).mockResolvedValue(true);
render(<ConfirmDialog isOpen onClose={onClose} onConfirm={onConfirm} title="Delete place" message="Gone for good." />);
await waitFor(() => expect(onClose).toHaveBeenCalledOnce());
expect(onConfirm).toHaveBeenCalledOnce();
expect(askNatively).toHaveBeenCalledTimes(1);
expect(askNatively).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Delete place', message: 'Gone for good.', danger: true }),
);
expect(screen.queryByText('Gone for good.')).toBeNull();
});
it('FE-COMP-CONFIRM-NATIVE-002: a no only closes', async () => {
vi.mocked(canAskNatively).mockReturnValue(true);
vi.mocked(askNatively).mockResolvedValue(false);
render(<ConfirmDialog isOpen onClose={onClose} onConfirm={onConfirm} message="Sure?" />);
await waitFor(() => expect(onClose).toHaveBeenCalledOnce());
expect(onConfirm).not.toHaveBeenCalled();
});
it('FE-COMP-CONFIRM-NATIVE-003: falls back to the HTML card when the system cannot ask', async () => {
vi.mocked(canAskNatively).mockReturnValue(true);
vi.mocked(askNatively).mockResolvedValue(null);
render(<ConfirmDialog isOpen onClose={onClose} onConfirm={onConfirm} message="Sure?" />);
expect(await screen.findByText('Sure?')).toBeTruthy();
expect(onClose).not.toHaveBeenCalled();
});
it('FE-COMP-CONFIRM-NATIVE-004: keeps a question with a list under it in HTML', () => {
vi.mocked(canAskNatively).mockReturnValue(true);
render(
<ConfirmDialog isOpen onClose={onClose} onConfirm={onConfirm} message="Sure?">
<ul><li>three places</li></ul>
</ConfirmDialog>,
);
expect(screen.getByText('three places')).toBeTruthy();
expect(askNatively).not.toHaveBeenCalled();
});
});
});