import React from 'react'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, fireEvent, act } from '@testing-library/react'; import MSheet from '../../../src/mobile/components/MSheet'; // FE-MOB-SHEET-001 onwards describe('MSheet', () => { afterEach(() => { vi.useRealTimers(); document.body.style.overflow = ''; }); it('FE-MOB-SHEET-001: renders its children in a dialog when open', () => { render( {}} ariaLabel="Test sheet"> Sheet content , ); expect(screen.getByRole('dialog', { name: 'Test sheet' })).toBeInTheDocument(); expect(screen.getByText('Sheet content')).toBeInTheDocument(); }); it('FE-MOB-SHEET-002: renders nothing while closed', () => { render( {}}> Sheet content , ); expect(screen.queryByText('Sheet content')).not.toBeInTheDocument(); }); it('FE-MOB-SHEET-003: backdrop click closes, clicks inside the panel do not', () => { const onClose = vi.fn(); render( Sheet content , ); fireEvent.click(screen.getByText('Sheet content')); expect(onClose).not.toHaveBeenCalled(); const dialog = screen.getByRole('dialog'); const backdrop = dialog.parentElement!.parentElement!; fireEvent.click(backdrop); expect(onClose).toHaveBeenCalledTimes(1); }); it('FE-MOB-SHEET-004: Escape closes the sheet', () => { const onClose = vi.fn(); render( Sheet content , ); fireEvent.keyDown(document, { key: 'Escape' }); expect(onClose).toHaveBeenCalledTimes(1); }); it('FE-MOB-SHEET-005: stays mounted for the exit animation, then unmounts', () => { vi.useFakeTimers(); const { rerender } = render( {}}> Sheet content , ); rerender( {}}> Sheet content , ); // Still visible right after closing (exit animation running). expect(screen.getByText('Sheet content')).toBeInTheDocument(); act(() => { vi.advanceTimersByTime(300); }); expect(screen.queryByText('Sheet content')).not.toBeInTheDocument(); }); it('FE-MOB-SHEET-007: Tab wraps around inside the dialog', () => { render( {}} ariaLabel="Trap"> , ); const dialog = screen.getByRole('dialog'); const first = screen.getByText('first'); const last = screen.getByText('last'); last.focus(); fireEvent.keyDown(dialog, { key: 'Tab' }); expect(document.activeElement).toBe(first); fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true }); expect(document.activeElement).toBe(last); }); it('FE-MOB-SHEET-008: shift+Tab from the panel itself jumps to the last control', () => { render( {}} ariaLabel="Trap"> , ); const dialog = screen.getByRole('dialog'); dialog.focus(); fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true }); expect(document.activeElement).toBe(screen.getByText('last')); }); it('FE-MOB-SHEET-009: keys other than Tab pass through', () => { render( {}} ariaLabel="Trap"> , ); const dialog = screen.getByRole('dialog'); const first = screen.getByText('first'); first.focus(); fireEvent.keyDown(dialog, { key: 'a' }); expect(document.activeElement).toBe(first); }); it('FE-MOB-SHEET-010: a sheet without focusable content swallows Tab', () => { render( {}} ariaLabel="Empty"> nothing to focus , ); const dialog = screen.getByRole('dialog'); const handled = fireEvent.keyDown(dialog, { key: 'Tab' }); // fireEvent returns false once a listener called preventDefault. expect(handled).toBe(false); }); it('FE-MOB-SHEET-011: the entrance animation class is dropped once it has played', () => { render( {}} ariaLabel="Anim"> Sheet content , ); const dialog = screen.getByRole('dialog'); expect(dialog.className).toContain('m-sheet-in'); // jsdom has no AnimationEvent, so React binds the vendor-prefixed name. act(() => { dialog.dispatchEvent(new Event('animationend', { bubbles: true })); dialog.dispatchEvent(new Event('webkitAnimationEnd', { bubbles: true })); }); expect(dialog.className).not.toContain('m-sheet-in'); }); it('FE-MOB-SHEET-012: dragging the bottom sheet far enough dismisses it', () => { HTMLElement.prototype.setPointerCapture = vi.fn(); const onClose = vi.fn(); render( Sheet content , ); const dialog = screen.getByRole('dialog'); Object.defineProperty(dialog, 'offsetHeight', { configurable: true, value: 400 }); const strip = dialog.querySelector('.cursor-grab') as HTMLElement; fireEvent.pointerDown(strip, { pointerId: 1, clientY: 100 }); fireEvent.pointerMove(strip, { pointerId: 1, clientY: 260 }); expect(dialog.getAttribute('style')).toContain('translateY(160px)'); // A slow last move keeps the velocity low, so only the 30% travel rule can close it. fireEvent.pointerMove(strip, { pointerId: 1, clientY: 260.3 }); fireEvent.pointerUp(strip, { pointerId: 1, clientY: 260.3 }); expect(onClose).toHaveBeenCalledTimes(1); }); it('FE-MOB-SHEET-015: a quick flick dismisses even without much travel', () => { HTMLElement.prototype.setPointerCapture = vi.fn(); const onClose = vi.fn(); render( Sheet content , ); const strip = screen.getByRole('dialog').querySelector('.cursor-grab') as HTMLElement; fireEvent.pointerDown(strip, { pointerId: 1, clientY: 100 }); fireEvent.pointerMove(strip, { pointerId: 1, clientY: 130 }); fireEvent.pointerUp(strip, { pointerId: 1, clientY: 130 }); expect(onClose).toHaveBeenCalledTimes(1); }); it('FE-MOB-SHEET-013: a short drag snaps back instead of closing', () => { HTMLElement.prototype.setPointerCapture = vi.fn(); const onClose = vi.fn(); render( Sheet content , ); const dialog = screen.getByRole('dialog'); Object.defineProperty(dialog, 'offsetHeight', { configurable: true, value: 400 }); const strip = dialog.querySelector('.cursor-grab') as HTMLElement; fireEvent.pointerDown(strip, { pointerId: 1, clientY: 100 }); fireEvent.pointerMove(strip, { pointerId: 1, clientY: 100.4 }); fireEvent.pointerCancel(strip, { pointerId: 1, clientY: 100.4 }); expect(onClose).not.toHaveBeenCalled(); expect(dialog.getAttribute('style')).toContain('transform 280ms'); }); it('FE-MOB-SHEET-014: pointer moves without a drag in progress are ignored', () => { const onClose = vi.fn(); render( Sheet content , ); const strip = screen.getByRole('dialog').querySelector('.cursor-grab') as HTMLElement; fireEvent.pointerMove(strip, { pointerId: 1, clientY: 400 }); fireEvent.pointerUp(strip, { pointerId: 1, clientY: 400 }); expect(onClose).not.toHaveBeenCalled(); }); it('FE-MOB-SHEET-006: locks body scroll while open and releases it on close', () => { const { rerender } = render( {}}> Sheet content , ); expect(document.body.style.overflow).toBe('hidden'); rerender( {}}> Sheet content , ); expect(document.body.style.overflow).toBe(''); }); it('FE-MOB-SHEET-016: Escape closes only the sheet on top, the one under it on the next press', () => { const onCloseBase = vi.fn(); const onCloseTop = vi.fn(); const { rerender } = render( <> Trip form Question , ); fireEvent.keyDown(document, { key: 'Escape' }); expect(onCloseTop).toHaveBeenCalledTimes(1); expect(onCloseBase).not.toHaveBeenCalled(); rerender( <> Trip form Question , ); fireEvent.keyDown(document, { key: 'Escape' }); expect(onCloseBase).toHaveBeenCalledTimes(1); expect(onCloseTop).toHaveBeenCalledTimes(1); }); it('FE-MOB-SHEET-017: a sheet re-rendered with a fresh onClose stays under the one opened after it', () => { const onCloseTop = vi.fn(); const baseCalls: number[] = []; const renderBoth = (round: number) => ( <> baseCalls.push(round)} ariaLabel="Trip">Trip form {round} Question ); const { rerender } = render(renderBoth(1)); // Typing into the form under the question re-renders it with a new handler. rerender(renderBoth(2)); fireEvent.keyDown(document, { key: 'Escape' }); expect(onCloseTop).toHaveBeenCalledTimes(1); expect(baseCalls).toEqual([]); }); describe('the unsaved-changes question', () => { function Form({ open, onClose }: { open: boolean; onClose: () => void }) { const [name, setName] = React.useState(''); return ( setName(e.target.value)} /> ); } const backdrop = () => screen.getByRole('dialog', { name: 'Form' }).parentElement!.parentElement!; it('FE-MOB-SHEET-020: a tap on the scrim asks before throwing a change away, and keep editing stays', () => { const onClose = vi.fn(); render(
); const input = screen.getByRole('textbox', { name: 'Name' }); fireEvent.pointerDown(input); fireEvent.change(input, { target: { value: 'Lisbon' } }); fireEvent.click(backdrop()); expect(onClose).not.toHaveBeenCalled(); expect(screen.getByRole('alertdialog')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /keep editing|common.keepEditing/i })); expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); expect(input).toHaveValue('Lisbon'); fireEvent.keyDown(document, { key: 'Escape' }); fireEvent.click(screen.getByRole('button', { name: /discard|common.discard/i })); expect(onClose).toHaveBeenCalledTimes(1); }); it('FE-MOB-SHEET-021: without a change it closes at once, and a reopened sheet starts afresh', () => { const onClose = vi.fn(); const { rerender } = render(); fireEvent.pointerDown(screen.getByRole('textbox', { name: 'Name' })); fireEvent.click(backdrop()); expect(onClose).toHaveBeenCalledTimes(1); rerender(); rerender(); fireEvent.click(backdrop()); expect(onClose).toHaveBeenCalledTimes(2); expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); }); }); });