Skip to content
Open
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
7 changes: 6 additions & 1 deletion apps/web/components/home/InGameScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,12 @@ export default function InGameScene({
</AnimatePresence>

{uiPhase === 'live_round' && (
<MinimapPanel onFinalize={onFinalizeGuess} canFinalizeGuess={canFinalizeGuess} guessSubmitted={guessSubmitted}>
<MinimapPanel
onFinalize={onFinalizeGuess}
canFinalizeGuess={canFinalizeGuess}
guessSubmitted={guessSubmitted}
roundKey={streetViewSrc}
>
{guessMapNode}
</MinimapPanel>
)}
Expand Down
122 changes: 122 additions & 0 deletions apps/web/components/ui/MinimapPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MinimapPanel from './MinimapPanel';

function mockMatchMedia(matches: boolean) {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}

type MinimapPanelProps = {
onFinalize?: () => void;
canFinalizeGuess?: boolean;
guessSubmitted?: boolean;
roundKey?: string;
};

function renderPanel(overrides: MinimapPanelProps = {}) {
const onFinalize = overrides.onFinalize ?? vi.fn();
const props = {
onFinalize,
canFinalizeGuess: overrides.canFinalizeGuess ?? false,
guessSubmitted: overrides.guessSubmitted ?? false,
roundKey: overrides.roundKey,
};

const view = render(
<MinimapPanel {...props}>
<div data-testid="guess-map">Map</div>
</MinimapPanel>,
);

return { ...view, onFinalize };
}

describe('MinimapPanel', () => {
afterEach(() => {
cleanup();
});

describe('mobile', () => {
beforeEach(() => {
mockMatchMedia(false);
});

it('shows only the Guess FAB when the map is closed', () => {
renderPanel();

expect(screen.getByRole('button', { name: 'Open map to place guess' })).toBeInTheDocument();
expect(screen.queryByTestId('guess-map')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Back to Street View' })).not.toBeInTheDocument();
});

it('opens the fullscreen map when the Guess FAB is clicked', () => {
renderPanel();

fireEvent.click(screen.getByRole('button', { name: 'Open map to place guess' }));

expect(screen.getByTestId('guess-map')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Back to Street View' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Place Pin' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Open map to place guess' })).not.toBeInTheDocument();
});

it('returns to Street View when Back is clicked', () => {
renderPanel();

fireEvent.click(screen.getByRole('button', { name: 'Open map to place guess' }));
fireEvent.click(screen.getByRole('button', { name: 'Back to Street View' }));

expect(screen.getByRole('button', { name: 'Open map to place guess' })).toBeInTheDocument();
expect(screen.queryByTestId('guess-map')).not.toBeInTheDocument();
});

it('closes the map when roundKey changes', () => {
const { rerender, onFinalize } = renderPanel({ roundKey: 'round-1' });

fireEvent.click(screen.getByRole('button', { name: 'Open map to place guess' }));
expect(screen.getByTestId('guess-map')).toBeInTheDocument();

rerender(
<MinimapPanel
onFinalize={onFinalize}
canFinalizeGuess={false}
guessSubmitted={false}
roundKey="round-2"
>
<div data-testid="guess-map">Map</div>
</MinimapPanel>,
);

expect(screen.getByRole('button', { name: 'Open map to place guess' })).toBeInTheDocument();
expect(screen.queryByTestId('guess-map')).not.toBeInTheDocument();
});
});

describe('desktop', () => {
beforeEach(() => {
mockMatchMedia(true);
});

it('renders the minimap panel without the mobile Guess FAB', () => {
renderPanel();

expect(screen.getByTestId('guess-map')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Place Pin' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Open map to place guess' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Back to Street View' })).not.toBeInTheDocument();
});
});
});
172 changes: 95 additions & 77 deletions apps/web/components/ui/MinimapPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import { ChevronLeft, MapPin } from 'lucide-react';

type Props = {
children: ReactNode;
onFinalize: () => void;
canFinalizeGuess: boolean;
guessSubmitted: boolean;
roundKey?: string;
};

export default function MinimapPanel({ children, onFinalize, canFinalizeGuess, guessSubmitted }: Props) {
const RIGHT_GUTTER_PX = 80;
const GESTURE_MOVE_THRESHOLD_PX = 6;
const GESTURE_MOVE_THRESHOLD_PX = 6;
const finalizeButtonClassName =
'font-hud relative z-10 min-h-11 w-full rounded-pill border border-emerald-200/35 bg-cta-gradient px-6 py-2 text-center text-sm uppercase tracking-[0.15em] text-white shadow-elev-3 transition hover:brightness-110 disabled:cursor-not-allowed';

export default function MinimapPanel({
children,
onFinalize,
canFinalizeGuess,
guessSubmitted,
roundKey,
}: Props) {
const panelRef = useRef<HTMLDivElement | null>(null);
const activePointerRef = useRef<{ id: number; x: number; y: number } | null>(null);
const resizeLockedRef = useRef(false);
const suppressNextPanelClickRef = useRef(false);
const [desktopHovered, setDesktopHovered] = useState(false);
const [mobileExpanded, setMobileExpanded] = useState(false);
const [mapViewOpen, setMapViewOpen] = useState(false);
const [isDesktop, setIsDesktop] = useState(false);

useEffect(() => {
Expand All @@ -26,7 +36,7 @@ export default function MinimapPanel({ children, onFinalize, canFinalizeGuess, g
const syncViewport = () => {
const desktop = mediaQuery.matches;
setIsDesktop(desktop);
if (desktop) setMobileExpanded(false);
if (desktop) setMapViewOpen(false);
};

syncViewport();
Expand All @@ -40,6 +50,10 @@ export default function MinimapPanel({ children, onFinalize, canFinalizeGuess, g
return () => mediaQuery.removeListener(syncViewport);
}, []);

useEffect(() => {
setMapViewOpen(false);
}, [roundKey]);

useEffect(() => {
if (typeof window === 'undefined') return;

Expand Down Expand Up @@ -70,15 +84,15 @@ export default function MinimapPanel({ children, onFinalize, canFinalizeGuess, g
};
}, [isDesktop]);

const expanded = isDesktop ? desktopHovered : mobileExpanded;
const reserveRightGutter = isDesktop || !mobileExpanded;
const expanded = desktopHovered;
const finalizeLabel = guessSubmitted ? 'Waiting for opponent...' : canFinalizeGuess ? 'Guess' : 'Place Pin';
const finalizeDisabledClassName = guessSubmitted ? 'opacity-45' : 'disabled:opacity-70';

const beginPointerGesture = (event: ReactPointerEvent) => {
activePointerRef.current = {
id: event.pointerId,
x: event.clientX,
y: event.clientY
y: event.clientY,
};
resizeLockedRef.current = true;
suppressNextPanelClickRef.current = false;
Expand All @@ -99,87 +113,91 @@ export default function MinimapPanel({ children, onFinalize, canFinalizeGuess, g
if (activePointerRef.current?.id === event.pointerId) activePointerRef.current = null;
};

const handlePanelClick = () => {
if (suppressNextPanelClickRef.current) {
suppressNextPanelClickRef.current = false;
return;
}
if (!isDesktop) setMobileExpanded(true);
};

const handleBackdropClick = () => {
if (suppressNextPanelClickRef.current) {
suppressNextPanelClickRef.current = false;
return;
}
setMobileExpanded(false);
};

const handleFinalizeClick = (event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onFinalize();
};

return (
<>
{!isDesktop && mobileExpanded ? (
const finalizeButton = (
<button
type="button"
className={`${finalizeButtonClassName} ${finalizeDisabledClassName}`}
onClick={handleFinalizeClick}
disabled={!canFinalizeGuess}
>
{finalizeLabel}
</button>
);

if (!isDesktop) {
if (!mapViewOpen) {
return (
<button
type="button"
aria-label="Collapse minimap"
className="absolute inset-0 z-20 cursor-default bg-transparent"
onPointerDown={beginPointerGesture}
onPointerMove={trackPointerGesture}
onPointerUp={endPointerGesture}
onPointerCancel={endPointerGesture}
onClick={handleBackdropClick}
/>
) : null}
<div
ref={panelRef}
onMouseEnter={(event) => {
if (!isDesktop) return;
if (resizeLockedRef.current || event.buttons !== 0) {
resizeLockedRef.current = true;
return;
}
setDesktopHovered(true);
}}
onMouseLeave={(event) => {
if (!isDesktop) return;
if (resizeLockedRef.current || event.buttons !== 0) {
resizeLockedRef.current = true;
return;
}
setDesktopHovered(false);
}}
className={`absolute bottom-0 right-0 z-30 flex w-full flex-col gap-2 p-3 transition-[width,height] duration-150 ease-out md:bottom-4 md:right-4 md:p-0 md:w-[min(34vw,460px)] md:h-[min(33vh,360px)] ${expanded ? 'md:w-[min(90vw,800px)] md:h-[min(52vh,560px)]' : ''
}`}
style={{
right: reserveRightGutter ? `${RIGHT_GUTTER_PX}px` : '0px',
width: isDesktop ? undefined : reserveRightGutter ? `calc(100% - ${RIGHT_GUTTER_PX}px)` : '100%'
}}
>
<div
onClick={handlePanelClick}
onPointerDown={beginPointerGesture}
onPointerMove={trackPointerGesture}
onPointerUp={endPointerGesture}
onPointerCancel={endPointerGesture}
className={`group relative min-h-0 w-full origin-bottom-right overflow-hidden rounded-panel border border-white/20 bg-slate-900/70 shadow-elev-4 transition-[height,opacity,box-shadow] duration-150 ease-out ${expanded
? 'h-[50vh] min-h-[280px] opacity-100 sm:h-[55vh] sm:min-h-[320px]'
: 'h-[22vh] min-h-[150px] opacity-70 sm:h-[27vh] sm:min-h-[190px]'
} md:h-auto md:min-h-0 md:flex-1 md:opacity-85 md:hover:opacity-100`}
aria-label="Open map to place guess"
onClick={() => setMapViewOpen(true)}
className="font-hud absolute bottom-4 right-3 z-40 flex min-h-11 items-center gap-2 rounded-pill border border-white/15 bg-hudBg px-4 text-sm uppercase tracking-[0.12em] text-white shadow-elev-2 backdrop-blur-hud transition hover:bg-white/10"
>
{children}
<MapPin size={16} strokeWidth={2.4} aria-hidden="true" />
Guess
</button>
);
}

return (
<>
<div className="absolute inset-0 z-30 flex flex-col gap-2 p-3">
<div className="min-h-0 flex-1 overflow-hidden rounded-panel border border-white/20 bg-slate-900/70 shadow-elev-4">
{children}
</div>
{finalizeButton}
</div>
<button
className={`font-hud relative z-10 min-h-11 w-full rounded-pill border border-emerald-200/35 bg-cta-gradient px-6 py-2 text-center text-sm uppercase tracking-[0.15em] text-white shadow-elev-3 transition hover:brightness-110 disabled:cursor-not-allowed ${guessSubmitted ? 'opacity-45' : 'disabled:opacity-70'}`}
onClick={handleFinalizeClick}
disabled={!canFinalizeGuess}
type="button"
aria-label="Back to Street View"
onClick={() => setMapViewOpen(false)}
className="font-hud absolute bottom-[calc(1rem+3.25rem)] right-3 z-40 flex min-h-11 items-center gap-2 rounded-pill border border-white/15 bg-hudBg px-4 text-sm uppercase tracking-[0.12em] text-white shadow-elev-2 backdrop-blur-hud transition hover:bg-white/10"
>
{finalizeLabel}
<ChevronLeft size={16} strokeWidth={2.4} aria-hidden="true" />
Back
</button>
</>
);
}

return (
<div
ref={panelRef}
onMouseEnter={(event) => {
if (resizeLockedRef.current || event.buttons !== 0) {
resizeLockedRef.current = true;
return;
}
setDesktopHovered(true);
}}
onMouseLeave={(event) => {
if (resizeLockedRef.current || event.buttons !== 0) {
resizeLockedRef.current = true;
return;
}
setDesktopHovered(false);
}}
className={`absolute bottom-0 right-0 z-30 flex w-full flex-col gap-2 p-3 transition-[width,height] duration-150 ease-out md:bottom-4 md:right-4 md:h-[min(33vh,360px)] md:w-[min(34vw,460px)] md:p-0 ${
expanded ? 'md:h-[min(52vh,560px)] md:w-[min(90vw,800px)]' : ''
}`}
>
<div
onPointerDown={beginPointerGesture}
onPointerMove={trackPointerGesture}
onPointerUp={endPointerGesture}
onPointerCancel={endPointerGesture}
className={`group relative min-h-0 w-full origin-bottom-right overflow-hidden rounded-panel border border-white/20 bg-slate-900/70 shadow-elev-4 transition-[height,opacity,box-shadow] duration-150 ease-out md:h-auto md:min-h-0 md:flex-1 md:opacity-85 md:hover:opacity-100 ${
expanded ? 'opacity-100' : 'opacity-70'
}`}
>
{children}
</div>
</>
{finalizeButton}
</div>
);
}
Loading