diff --git a/web/src/components/BattlePhase.tsx b/web/src/components/BattlePhase.tsx index 6da84f9..013edcf 100644 --- a/web/src/components/BattlePhase.tsx +++ b/web/src/components/BattlePhase.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react' +import { createPortal } from 'react-dom' import type { BattleEvent, Card, ClientMessage, GameView } from '../types' import { CardView } from './CardView' import { DiceTray } from './Dice' @@ -175,7 +176,13 @@ export function BattlePhase({ view, send }: Props) { const events = battle.events ?? [] const [step, setStep] = useState(0) const [acked, setAcked] = useState(false) - const [peekSeat, setPeekSeat] = useState(null) + // The deck-peek popover lives inside .battlefield, which sets overflow-x + // (and thus overflow-y) to auto — so an absolutely-positioned popover gets + // clipped to the battlefield. We anchor it to the hovered stack's viewport + // rect and portal it to so it floats over the whole window instead. + const [peek, setPeek] = useState<{ seat: number; dir: 'left' | 'right'; rect: DOMRect } | null>( + null, + ) const lineups = battle.lineups const done = step >= events.length const lastEvent = step > 0 ? events[step - 1] : null @@ -226,8 +233,12 @@ export function BattlePhase({ view, send }: Props) { const stackEl = (
setPeekSeat(seat) : undefined} - onMouseLeave={() => setPeekSeat((s) => (s === seat ? null : s))} + onMouseEnter={ + lineup.length + ? (e) => setPeek({ seat, dir, rect: e.currentTarget.getBoundingClientRect() }) + : undefined + } + onMouseLeave={() => setPeek((p) => (p?.seat === seat ? null : p))} title={lineup.length ? 'Hover to see the whole deck' : undefined} > {s.stack > 0 ? ( @@ -247,19 +258,6 @@ export function BattlePhase({ view, send }: Props) {
)} - {peekSeat === seat && lineup.length > 0 && ( -
-
- {seat === youSeat ? 'Your' : 'Opponent’s'} deck · {lineup.length} card - {lineup.length !== 1 ? 's' : ''} (top first) -
-
- {lineup.map((c, i) => ( - - ))} -
-
- )} ) @@ -393,6 +391,32 @@ export function BattlePhase({ view, send }: Props) { {renderSide(oppSeat, 'right')} + {peek && + (() => { + const lineup = lineups?.[peek.seat] ?? [] + if (!lineup.length) return null + const style: React.CSSProperties = { + bottom: window.innerHeight - peek.rect.top + 8, + ...(peek.dir === 'left' + ? { left: peek.rect.left } + : { right: window.innerWidth - peek.rect.right }), + } + return createPortal( +
+
+ {peek.seat === youSeat ? 'Your' : 'Opponent’s'} deck · {lineup.length} card + {lineup.length !== 1 ? 's' : ''} (top first) +
+
+ {lineup.map((c, i) => ( + + ))} +
+
, + document.body, + ) + })()} + {done && (
diff --git a/web/src/styles.css b/web/src/styles.css index 2bf0b76..8320651 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1399,9 +1399,11 @@ h3 { cursor: help; } +/* Portaled to and anchored to the hovered stack's viewport rect (see + BattlePhase) so it floats over the whole window instead of being clipped by + the battlefield's overflow. left/right/bottom come from inline styles. */ .deck-peek { - position: absolute; - bottom: calc(100% + 8px); + position: fixed; z-index: 60; width: max-content; max-width: 60vw; @@ -1410,14 +1412,7 @@ h3 { border-radius: 10px; padding: 8px 10px; box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5); -} - -.deck-peek-left { - left: 0; -} - -.deck-peek-right { - right: 0; + pointer-events: none; } .deck-peek-label {