diff --git a/src/components/ConfirmDialog.test.tsx b/src/components/ConfirmDialog.test.tsx new file mode 100644 index 00000000..1d110fdb --- /dev/null +++ b/src/components/ConfirmDialog.test.tsx @@ -0,0 +1,105 @@ +import { describe, it, expect, vi } from 'vitest'; +import { screen, fireEvent, within } from '@testing-library/react'; +import { renderWithProviders } from '@/test/helpers/renderWithProviders'; +import ConfirmDialog from './ConfirmDialog'; + +/** Footer-scoped button query — the header X shares the cancel label by design. */ +const footer = () => within(document.querySelector('.ui-modal-footer') as HTMLElement); + +describe('ConfirmDialog', () => { + it('renders title, message and both buttons', () => { + renderWithProviders( + , + ); + expect(screen.getByText('Delete playlist?')).toBeInTheDocument(); + expect(screen.getByText('This cannot be undone.')).toBeInTheDocument(); + expect(footer().getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + expect(footer().getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + it('fires onConfirm and onCancel from their buttons', () => { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + renderWithProviders( + , + ); + fireEvent.click(footer().getByRole('button', { name: 'OK' })); + expect(onConfirm).toHaveBeenCalledOnce(); + fireEvent.click(footer().getByRole('button', { name: 'No' })); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it('gives the confirm button initial focus', () => { + renderWithProviders( + , + ); + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Confirm' })); + }); + + it('applies danger styling to the confirm button', () => { + renderWithProviders( + , + ); + expect(footer().getByRole('button', { name: 'Delete' })).toHaveStyle({ + background: 'var(--danger)', + }); + }); + + it('renders a single button and resolves via onConfirm on Escape', () => { + const onConfirm = vi.fn(); + renderWithProviders( + , + ); + expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull(); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onConfirm).toHaveBeenCalledOnce(); + }); + + it('disables buttons and blocks dismissal while busy', () => { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + renderWithProviders( + , + ); + expect(footer().getByRole('button', { name: 'OK' })).toBeDisabled(); + expect(footer().getByRole('button', { name: 'Cancel' })).toBeDisabled(); + + fireEvent.keyDown(window, { key: 'Escape' }); + fireEvent.click(screen.getByRole('dialog').parentElement!); + expect(onCancel).not.toHaveBeenCalled(); + expect(onConfirm).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/Modal.test.tsx b/src/components/Modal.test.tsx new file mode 100644 index 00000000..0a687b55 --- /dev/null +++ b/src/components/Modal.test.tsx @@ -0,0 +1,180 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createRef } from 'react'; +import { screen, fireEvent, render } from '@testing-library/react'; +import { renderWithProviders } from '@/test/helpers/renderWithProviders'; +import Modal from './Modal'; + +describe('Modal', () => { + it('renders nothing when closed', () => { + renderWithProviders( + + body + , + ); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('renders title, subtitle, icon, body and footer when open', () => { + renderWithProviders( + } + footer={} + > +

body content

+
, + ); + expect(screen.getByText('My title')).toBeInTheDocument(); + expect(screen.getByText('My subtitle')).toBeInTheDocument(); + expect(screen.getByTestId('icon')).toBeInTheDocument(); + expect(screen.getByText('body content')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Do it' })).toBeInTheDocument(); + }); + + it('portals into document.body', () => { + renderWithProviders( + + body + , + ); + const dialog = screen.getByRole('dialog'); + expect(dialog.closest('.ui-modal-backdrop')?.parentElement).toBe(document.body); + }); + + it('labels the dialog with the title element id', () => { + renderWithProviders( + + body + , + ); + const dialog = screen.getByRole('dialog'); + const labelledBy = dialog.getAttribute('aria-labelledby'); + expect(labelledBy).toBeTruthy(); + expect(document.getElementById(labelledBy!)).toHaveTextContent('Accessible'); + }); + + it('calls onClose on Escape, backdrop click and the X button', async () => { + const onClose = vi.fn(); + renderWithProviders( + + body + , + ); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole('dialog').parentElement!); + expect(onClose).toHaveBeenCalledTimes(2); + + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(onClose).toHaveBeenCalledTimes(3); + }); + + it('does not close on Escape when closeOnEscape is false', () => { + const onClose = vi.fn(); + renderWithProviders( + + body + , + ); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('does not close on backdrop click when closeOnBackdrop is false', () => { + const onClose = vi.fn(); + renderWithProviders( + + body + , + ); + fireEvent.click(screen.getByRole('dialog').parentElement!); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('hides the close button when hideClose is set', () => { + renderWithProviders( + + body + , + ); + expect(screen.queryByRole('button', { name: 'Close' })).toBeNull(); + }); + + it('focuses the initialFocusRef target on open', () => { + const ref = createRef(); + renderWithProviders( + Primary}> + body + , + ); + expect(document.activeElement).toBe(ref.current); + }); + + it('focuses the first body field when no initialFocusRef is given', () => { + renderWithProviders( + + + , + ); + expect(document.activeElement).toBe(screen.getByLabelText('name')); + }); + + it('traps Tab focus inside the dialog', () => { + renderWithProviders( + Last}> + body + , + ); + const closeBtn = screen.getByRole('button', { name: 'Close' }); + const lastBtn = screen.getByRole('button', { name: 'Last' }); + + lastBtn.focus(); + fireEvent.keyDown(window, { key: 'Tab' }); + expect(document.activeElement).toBe(closeBtn); + + closeBtn.focus(); + fireEvent.keyDown(window, { key: 'Tab', shiftKey: true }); + expect(document.activeElement).toBe(lastBtn); + }); + + it('restores focus to the opening element on close', () => { + const onClose = vi.fn(); + const { rerender } = render( + <> + + + body + + , + ); + const opener = screen.getByTestId('opener'); + opener.focus(); + expect(document.activeElement).toBe(opener); + + rerender( + <> + + + body + + , + ); + // Modal grabbed focus. + expect(document.activeElement).not.toBe(opener); + + rerender( + <> + + + body + + , + ); + expect(document.activeElement).toBe(opener); + }); +});