https://checkmate.social
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add MoveDrawer component for mobile move history

Bottom-sheet drawer that wraps the existing MoveList. Two stateless
pieces (trigger + panel) so the trigger can be slotted into PlayerBar
while the panel sits at screen level. Dismissible via backdrop tap,
close button, or Escape key. Mobile-only (md:hidden); desktop continues
to use the sidebar MoveList.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

jcalabro 55110e6a 8131583a

+204
+116
client/src/components/game/MoveDrawer.tsx
··· 1 + /** 2 + * MoveDrawer — bottom-sheet drawer that displays the move history on mobile. 3 + * 4 + * Composed of two stateless pieces so the trigger can be slotted into the 5 + * `PlayerBar`'s action slot while the panel sits at the screen level: 6 + * 7 + * - `MoveDrawerTrigger` — small "Moves" button shown only on mobile (md:hidden) 8 + * - `MoveDrawerPanel` — backdrop + slide-up sheet wrapping <MoveList /> 9 + * 10 + * State (open/closed) lives in the consumer (GameScreen) because the trigger 11 + * and panel are visually independent and the consumer is responsible for 12 + * closing the drawer when, e.g., the active game changes. 13 + * 14 + * The panel is dismissible via: 15 + * - Tapping the backdrop 16 + * - Tapping the X button 17 + * - Pressing Escape 18 + * 19 + * Both pieces are gated on `md:hidden` — the desktop sidebar MoveList in 20 + * GameScreen is the desktop treatment. 21 + */ 22 + 23 + import { useEffect } from 'react'; 24 + import { MoveList } from './MoveList'; 25 + 26 + interface Move { 27 + moveNumber: number; 28 + san: string; 29 + } 30 + 31 + // ─── Trigger ───────────────────────────────────────────────────────────────── 32 + 33 + interface MoveDrawerTriggerProps { 34 + onOpen: () => void; 35 + /** Used in the aria-label so screen readers know how many moves exist */ 36 + count: number; 37 + } 38 + 39 + export function MoveDrawerTrigger({ onOpen, count }: MoveDrawerTriggerProps) { 40 + return ( 41 + <button 42 + onClick={onOpen} 43 + className="flex min-h-[44px] items-center gap-1 rounded-md border border-wood-600 bg-wood-900 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-wood-200 shadow-sm transition-colors hover:border-wood-400 hover:text-wood-100 md:hidden" 44 + aria-label={`Open move list (${count} ${count === 1 ? 'move' : 'moves'})`} 45 + > 46 + Moves 47 + <span aria-hidden="true" className="text-wood-500">▴</span> 48 + </button> 49 + ); 50 + } 51 + 52 + // ─── Panel ─────────────────────────────────────────────────────────────────── 53 + 54 + interface MoveDrawerPanelProps { 55 + open: boolean; 56 + onClose: () => void; 57 + moves: Move[]; 58 + } 59 + 60 + export function MoveDrawerPanel({ open, onClose, moves }: MoveDrawerPanelProps) { 61 + // Close on Escape — only attach the listener while open so the closed 62 + // panel doesn't intercept keystrokes meant for other parts of the page. 63 + useEffect(() => { 64 + if (!open) return; 65 + const onKey = (e: KeyboardEvent) => { 66 + if (e.key === 'Escape') onClose(); 67 + }; 68 + document.addEventListener('keydown', onKey); 69 + return () => document.removeEventListener('keydown', onKey); 70 + }, [open, onClose]); 71 + 72 + if (!open) return null; 73 + 74 + return ( 75 + <div className="md:hidden" role="dialog" aria-modal="true" aria-label="Move history"> 76 + {/* Backdrop — fixed full-screen, dismisses on tap */} 77 + <div 78 + data-testid="move-drawer-backdrop" 79 + className="fixed inset-0 z-20 bg-black/60" 80 + onClick={onClose} 81 + aria-hidden="true" 82 + /> 83 + 84 + {/* Sheet — pinned to bottom of viewport, ≤70vh tall */} 85 + <div 86 + className="fixed inset-x-0 bottom-0 z-30 flex max-h-[70vh] flex-col rounded-t-xl border-t border-wood-600 bg-wood-800 shadow-2xl" 87 + // env() pads around iOS Safari's home-indicator strip so the close 88 + // button isn't occluded when the bottom toolbar collapses. 89 + style={{ paddingBottom: 'env(safe-area-inset-bottom)' }} 90 + > 91 + {/* Grab handle (visual affordance only — swipe-to-dismiss is a 92 + future enhancement, not in this scope) */} 93 + <div aria-hidden="true" className="mx-auto mt-2 h-1 w-10 rounded-full bg-wood-500" /> 94 + 95 + {/* Header row */} 96 + <div className="flex items-center justify-between px-4 py-2"> 97 + <h3 className="text-xs font-medium uppercase tracking-widest text-wood-300"> 98 + Moves 99 + </h3> 100 + <button 101 + onClick={onClose} 102 + className="flex min-h-[44px] min-w-[44px] items-center justify-center text-lg text-wood-400 transition-colors hover:text-wood-100" 103 + aria-label="Close move list" 104 + > 105 + 106 + </button> 107 + </div> 108 + 109 + {/* Move list — fills remaining height inside the sheet */} 110 + <div className="min-h-0 flex-1 overflow-hidden px-4 pb-4"> 111 + <MoveList moves={moves} /> 112 + </div> 113 + </div> 114 + </div> 115 + ); 116 + }
+88
client/src/components/game/__tests__/MoveDrawer.test.tsx
··· 1 + /** 2 + * MoveDrawer component tests. 3 + * 4 + * The drawer is two stateless pieces — a trigger and a panel. State lives 5 + * in the consumer; tests pass `open` / `onClose` / `onOpen` directly. 6 + */ 7 + 8 + import { describe, it, expect, vi } from 'vitest'; 9 + import { render, screen, fireEvent } from '@testing-library/react'; 10 + import { MoveDrawerTrigger, MoveDrawerPanel } from '../MoveDrawer'; 11 + 12 + describe('MoveDrawerTrigger', () => { 13 + it('renders a Moves button', () => { 14 + render(<MoveDrawerTrigger onOpen={vi.fn()} count={0} />); 15 + const btn = screen.getByRole('button', { name: /open move list/i }); 16 + expect(btn).toBeInTheDocument(); 17 + expect(btn).toHaveTextContent(/moves/i); 18 + }); 19 + 20 + it('calls onOpen when clicked', () => { 21 + const onOpen = vi.fn(); 22 + render(<MoveDrawerTrigger onOpen={onOpen} count={3} />); 23 + fireEvent.click(screen.getByRole('button')); 24 + expect(onOpen).toHaveBeenCalledOnce(); 25 + }); 26 + }); 27 + 28 + describe('MoveDrawerPanel', () => { 29 + it('renders nothing when closed', () => { 30 + const { container } = render( 31 + <MoveDrawerPanel open={false} onClose={vi.fn()} moves={[]} /> 32 + ); 33 + expect(container.firstChild).toBeNull(); 34 + }); 35 + 36 + it('renders a dialog when open', () => { 37 + render(<MoveDrawerPanel open={true} onClose={vi.fn()} moves={[]} />); 38 + expect(screen.getByRole('dialog')).toBeInTheDocument(); 39 + }); 40 + 41 + it('shows the move list inside the panel', () => { 42 + render( 43 + <MoveDrawerPanel 44 + open={true} 45 + onClose={vi.fn()} 46 + moves={[ 47 + { moveNumber: 1, san: 'e4' }, 48 + { moveNumber: 2, san: 'e5' }, 49 + ]} 50 + /> 51 + ); 52 + expect(screen.getByText('e4')).toBeInTheDocument(); 53 + expect(screen.getByText('e5')).toBeInTheDocument(); 54 + }); 55 + 56 + it('shows empty-state via underlying MoveList when moves is empty', () => { 57 + render(<MoveDrawerPanel open={true} onClose={vi.fn()} moves={[]} />); 58 + expect(screen.getByText('No moves yet')).toBeInTheDocument(); 59 + }); 60 + 61 + it('calls onClose when the close button is clicked', () => { 62 + const onClose = vi.fn(); 63 + render(<MoveDrawerPanel open={true} onClose={onClose} moves={[]} />); 64 + fireEvent.click(screen.getByLabelText(/close move list/i)); 65 + expect(onClose).toHaveBeenCalledOnce(); 66 + }); 67 + 68 + it('calls onClose when the backdrop is clicked', () => { 69 + const onClose = vi.fn(); 70 + render(<MoveDrawerPanel open={true} onClose={onClose} moves={[]} />); 71 + fireEvent.click(screen.getByTestId('move-drawer-backdrop')); 72 + expect(onClose).toHaveBeenCalledOnce(); 73 + }); 74 + 75 + it('calls onClose when Escape is pressed while open', () => { 76 + const onClose = vi.fn(); 77 + render(<MoveDrawerPanel open={true} onClose={onClose} moves={[]} />); 78 + fireEvent.keyDown(document, { key: 'Escape' }); 79 + expect(onClose).toHaveBeenCalledOnce(); 80 + }); 81 + 82 + it('does not respond to Escape when closed', () => { 83 + const onClose = vi.fn(); 84 + render(<MoveDrawerPanel open={false} onClose={onClose} moves={[]} />); 85 + fireEvent.keyDown(document, { key: 'Escape' }); 86 + expect(onClose).not.toHaveBeenCalled(); 87 + }); 88 + });