feat(chess): add responsive mobile board interface#691
Conversation
- Add touch drag support to ChessboardComponent (touchstart/move/end) - Highlight target square during touch drag with teal inset ring - Add mobile action row (Flip + Resign + status) below board on mobile - Make move history collapsible via toggle on mobile - Hide desktop controls on mobile (lg:hidden/hidden lg:flex) - Hide socket status indicator from Game Status card on mobile
|
@raymondjoseph02 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
📝 WalkthroughWalkthroughThis pull request enhances the chess application with mobile responsiveness and touch-based interaction. The play page now detects mobile layout, adds a collapsible move history section, and implements a mobile control bar beneath the board. The chessboard component adds touch event handling with visual feedback for square selection and piece movement. Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (2)
frontend/components/chess/ChessboardComponent.tsx (2)
278-320: Touch path won't respect a future board flip.
attemptMovederives algebraic squares from row/col with8 - row/97 + col, which assumes white-on-bottom orientation. The PR's mobile control bar already exposes a "Flip Board" button (Line 292 ofpage.tsx); once that is wired to aboardOrientationprop, bothgetSquareFromTouchand the click path will produce inverted moves for the black-on-bottom case. Worth threading orientation throughattemptMove(and the touch indices) when implementing flip so this code doesn't silently break.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/components/chess/ChessboardComponent.tsx` around lines 278 - 320, Touch handlers and getSquareFromTouch assume white-on-bottom and will produce inverted moves when boardOrientation is flipped; update the touch-to-square mapping and move invocation to respect orientation by either: 1) making getSquareFromTouch accept the boardOrientation prop and flip the computed row/col when boardOrientation === "black" (e.g., row -> 7 - row, col -> 7 - col), and ensure handleTouchStart/handleTouchMove/handleTouchEnd use that oriented mapping; or 2) change attemptMove to accept an orientation parameter and convert source/target rows/cols inside attemptMove using the same flip logic before computing algebraic squares; modify the references to getSquareFromTouch and attemptMove accordingly (functions: getSquareFromTouch, handleTouchStart, handleTouchMove, handleTouchEnd, attemptMove) so the flipped board produces correct moves.
289-296: Unusedeparameter — and consideruseStateinstead of a ref fortouchStartSquareif you want it to drive UI.Currently
eis unused; either prefix with_or drop it. Also,touchStartSquareis a ref so changes don't re-render — fine for this implementation, just confirming it's intentional that no UI reflects the start square distinctly fromselectedSquare.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/components/chess/ChessboardComponent.tsx` around lines 289 - 296, The handleTouchStart function currently declares an unused parameter e; remove it or rename it to _e to signal it's unused, and keep the guard and calls to touchStartSquare.current and setSelectedSquare as-is; if you intend the touch start to drive UI updates (so changes should re-render), replace the touchStartSquare ref with React state (e.g., useState) and update that state inside handleTouchStart instead of assigning touchStartSquare.current. Ensure you update any consumers of touchStartSquare to read from the new state variable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@frontend/app/play/`[slug]/page.tsx:
- Around line 290-313: The "Flip Board" button currently has an empty onClick
handler; either wire it to toggle the board orientation or disable it until
implemented. Add or reuse a boolean state like boardOrientation (or create a
toggleBoardOrientation handler) and pass boardOrientation into the
ChessboardComponent, then change onClick={() => {}} to
onClick={toggleBoardOrientation}; alternatively set disabled={true} and adjust
classes to show the disabled state for both the mobile button here and the
corresponding desktop button (same "Flip Board" control in the desktop block) so
the UI does not present a non-functional control.
- Around line 356-366: The header button currently uses onClick={() => isMobile
&& setIsMoveHistoryOpen(...)} which makes it focusable and clickable on desktop
but a no-op; change this so the interactive button is only rendered when mobile
(use a conditional to render the <button> with onClick that toggles
isMoveHistoryOpen and render a plain <h3> for desktop) or make the element
non-interactive on desktop (remove button role/handlers when !isMobile). Also
add id="move-history-list" to the moves container <div> and prefer CSS-driven
visibility for desktop (e.g., ensure the container uses a lg:!block or
equivalent class so layout doesn’t rely on isMobile during hydration). Ensure
you update references to isMoveHistoryOpen and setIsMoveHistoryOpen accordingly.
- Around line 52-53: The hydration flicker is caused by using the client-only
useIsMobile to toggle classes; replace the JS-driven visibility class logic that
references isMobile and isMoveHistoryOpen with Tailwind responsive utilities
(e.g., use "hidden lg:block" on the move-history panel and "lg:hidden" for
mobile-only elements) so the server and client markup match, and change the
toggle button's aria-expanded to rely solely on isMoveHistoryOpen (remove any
isMobile-based conditional) so aria-expanded no longer flips during hydration.
In `@frontend/components/chess/ChessboardComponent.tsx`:
- Around line 278-287: The touch-to-square mapping in getSquareFromTouch
currently uses the full bounding rect (boardRef and getBoundingClientRect) which
includes the container's 1% padding, causing incorrect col/row math; fix it by
computing the inner content box before mapping: read the board element's
computed padding (e.g., via getComputedStyle on boardRef.current) or calculate
padding pixels from rect.width/height and the known 1% value, subtract
left/right and top/bottom padding to build an innerRect (innerLeft, innerTop,
innerWidth, innerHeight), then compute x = touch.clientX - innerLeft and y =
touch.clientY - innerTop and derive col/row as Math.floor((x / innerWidth) * 8)
and Math.floor((y / innerHeight) * 8), returning null when outside 0–7; update
getSquareFromTouch to use this inner content rect.
- Around line 307-320: The touchend handler handleTouchEnd should call
e.preventDefault() to suppress the synthetic click that re-selects the moved
piece: inside handleTouchEnd (before/after computing sq) when a destination
square is detected and you call attemptMove, invoke e.preventDefault() so
handleSquareClick does not receive a synthetic click after the move; ensure
touchStartSquare.current, setSelectedSquare(null), and setHoveredSquare(null)
logic remains unchanged so state clearing still happens.
---
Nitpick comments:
In `@frontend/components/chess/ChessboardComponent.tsx`:
- Around line 278-320: Touch handlers and getSquareFromTouch assume
white-on-bottom and will produce inverted moves when boardOrientation is
flipped; update the touch-to-square mapping and move invocation to respect
orientation by either: 1) making getSquareFromTouch accept the boardOrientation
prop and flip the computed row/col when boardOrientation === "black" (e.g., row
-> 7 - row, col -> 7 - col), and ensure
handleTouchStart/handleTouchMove/handleTouchEnd use that oriented mapping; or 2)
change attemptMove to accept an orientation parameter and convert source/target
rows/cols inside attemptMove using the same flip logic before computing
algebraic squares; modify the references to getSquareFromTouch and attemptMove
accordingly (functions: getSquareFromTouch, handleTouchStart, handleTouchMove,
handleTouchEnd, attemptMove) so the flipped board produces correct moves.
- Around line 289-296: The handleTouchStart function currently declares an
unused parameter e; remove it or rename it to _e to signal it's unused, and keep
the guard and calls to touchStartSquare.current and setSelectedSquare as-is; if
you intend the touch start to drive UI updates (so changes should re-render),
replace the touchStartSquare ref with React state (e.g., useState) and update
that state inside handleTouchStart instead of assigning
touchStartSquare.current. Ensure you update any consumers of touchStartSquare to
read from the new state variable.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 34439467-39c5-41ab-97e9-71c90ba4e65f
⛔ Files ignored due to path filters (1)
frontend/package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (2)
frontend/app/play/[slug]/page.tsxfrontend/components/chess/ChessboardComponent.tsx
| const [isMoveHistoryOpen, setIsMoveHistoryOpen] = useState(false); | ||
| const isMobile = useIsMobile(); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
fd -t f 'use-mobile*' frontend/hook frontend/hooks 2>/dev/null
fd -t f 'use-mobile' | head
fd -t f 'use-mobile' --exec cat {}Repository: NOVUS-X/XLMate
Length of output: 661
🏁 Script executed:
cat -n frontend/app/play/[slug]/page.tsx | sed -n '50,70p'Repository: NOVUS-X/XLMate
Length of output: 837
🏁 Script executed:
cat -n frontend/app/play/[slug]/page.tsx | sed -n '360,375p'Repository: NOVUS-X/XLMate
Length of output: 931
🏁 Script executed:
grep -n "aria-expanded" frontend/app/play/[slug]/page.tsxRepository: NOVUS-X/XLMate
Length of output: 130
🏁 Script executed:
head -5 frontend/app/play/[slug]/page.tsxRepository: NOVUS-X/XLMate
Length of output: 208
Hydration mismatch causing first-paint flicker via useIsMobile.
The page renders with isMobile=false (hook's initial state), then re-renders on the client after useEffect resolves the actual mobile status. This creates two issues:
- The move-history panel at line 366 briefly displays on mobile (
hiddenclass is false server-side, then true after hydration) - The
aria-expandedattribute at line 359 flips fromtrue(server) toisMoveHistoryOpen(client mobile)
Replace the JS-driven visibility toggle with Tailwind's responsive utilities: use hidden lg:block / lg:hidden for the panel instead of isMobile && !isMoveHistoryOpen ? "hidden" : "" to eliminate the flash and markup divergence between server and client renders.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/app/play/`[slug]/page.tsx around lines 52 - 53, The hydration
flicker is caused by using the client-only useIsMobile to toggle classes;
replace the JS-driven visibility class logic that references isMobile and
isMoveHistoryOpen with Tailwind responsive utilities (e.g., use "hidden
lg:block" on the move-history panel and "lg:hidden" for mobile-only elements) so
the server and client markup match, and change the toggle button's aria-expanded
to rely solely on isMoveHistoryOpen (remove any isMobile-based conditional) so
aria-expanded no longer flips during hydration.
| {/* Mobile controls — visible only on mobile, directly below board */} | ||
| <div className="flex items-center gap-2 mt-3 lg:hidden"> | ||
| <button | ||
| onClick={() => {}} | ||
| aria-label="Flip board orientation" | ||
| className="flex-1 py-2.5 rounded-xl bg-gray-800/60 hover:bg-gray-700/60 border border-gray-700/50 text-gray-300 text-sm font-medium transition-all duration-300" | ||
| > | ||
| Flip Board | ||
| </button> | ||
| <button | ||
| onClick={handleResign} | ||
| disabled={gameStatus !== "playing"} | ||
| aria-label="Resign game" | ||
| className="flex-1 py-2.5 rounded-xl bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 text-red-400 text-sm font-medium transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed" | ||
| > | ||
| Resign | ||
| </button> | ||
| <div className="flex items-center gap-1.5 px-2 shrink-0"> | ||
| <FaSignal className={`text-xs ${socketStatusColor()}`} /> | ||
| <span className={`text-xs ${socketStatusColor()}`}> | ||
| {socketStatusLabel()} | ||
| </span> | ||
| </div> | ||
| </div> |
There was a problem hiding this comment.
"Flip Board" button is a non-functional placeholder.
onClick={() => {}} ships a visibly-active control that does nothing when tapped, which is a poor UX (and the same placeholder exists at Line 404 in the desktop block). Either wire it to a real flip handler (a boardOrientation state passed to ChessboardComponent) or disable/omit the button until the feature lands.
Suggested interim fix (disable until implemented)
- <button
- onClick={() => {}}
- aria-label="Flip board orientation"
- className="flex-1 py-2.5 rounded-xl bg-gray-800/60 hover:bg-gray-700/60 border border-gray-700/50 text-gray-300 text-sm font-medium transition-all duration-300"
- >
+ <button
+ type="button"
+ onClick={() => {}}
+ disabled
+ aria-label="Flip board orientation (coming soon)"
+ className="flex-1 py-2.5 rounded-xl bg-gray-800/60 border border-gray-700/50 text-gray-300 text-sm font-medium opacity-50 cursor-not-allowed"
+ >
Flip Board
</button>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| {/* Mobile controls — visible only on mobile, directly below board */} | |
| <div className="flex items-center gap-2 mt-3 lg:hidden"> | |
| <button | |
| onClick={() => {}} | |
| aria-label="Flip board orientation" | |
| className="flex-1 py-2.5 rounded-xl bg-gray-800/60 hover:bg-gray-700/60 border border-gray-700/50 text-gray-300 text-sm font-medium transition-all duration-300" | |
| > | |
| Flip Board | |
| </button> | |
| <button | |
| onClick={handleResign} | |
| disabled={gameStatus !== "playing"} | |
| aria-label="Resign game" | |
| className="flex-1 py-2.5 rounded-xl bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 text-red-400 text-sm font-medium transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| Resign | |
| </button> | |
| <div className="flex items-center gap-1.5 px-2 shrink-0"> | |
| <FaSignal className={`text-xs ${socketStatusColor()}`} /> | |
| <span className={`text-xs ${socketStatusColor()}`}> | |
| {socketStatusLabel()} | |
| </span> | |
| </div> | |
| </div> | |
| {/* Mobile controls — visible only on mobile, directly below board */} | |
| <div className="flex items-center gap-2 mt-3 lg:hidden"> | |
| <button | |
| type="button" | |
| onClick={() => {}} | |
| disabled | |
| aria-label="Flip board orientation (coming soon)" | |
| className="flex-1 py-2.5 rounded-xl bg-gray-800/60 border border-gray-700/50 text-gray-300 text-sm font-medium opacity-50 cursor-not-allowed" | |
| > | |
| Flip Board | |
| </button> | |
| <button | |
| onClick={handleResign} | |
| disabled={gameStatus !== "playing"} | |
| aria-label="Resign game" | |
| className="flex-1 py-2.5 rounded-xl bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 text-red-400 text-sm font-medium transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| Resign | |
| </button> | |
| <div className="flex items-center gap-1.5 px-2 shrink-0"> | |
| <FaSignal className={`text-xs ${socketStatusColor()}`} /> | |
| <span className={`text-xs ${socketStatusColor()}`}> | |
| {socketStatusLabel()} | |
| </span> | |
| </div> | |
| </div> |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/app/play/`[slug]/page.tsx around lines 290 - 313, The "Flip Board"
button currently has an empty onClick handler; either wire it to toggle the
board orientation or disable it until implemented. Add or reuse a boolean state
like boardOrientation (or create a toggleBoardOrientation handler) and pass
boardOrientation into the ChessboardComponent, then change onClick={() => {}} to
onClick={toggleBoardOrientation}; alternatively set disabled={true} and adjust
classes to show the disabled state for both the mobile button here and the
corresponding desktop button (same "Flip Board" control in the desktop block) so
the UI does not present a non-functional control.
| <button | ||
| className="flex items-center justify-between w-full text-left" | ||
| onClick={() => isMobile && setIsMoveHistoryOpen((prev) => !prev)} | ||
| aria-expanded={isMobile ? isMoveHistoryOpen : true} | ||
| > | ||
| <h3 className="text-sm font-semibold text-gray-300">Moves</h3> | ||
| <span className="text-gray-500 text-xs lg:hidden"> | ||
| {isMoveHistoryOpen ? "▲" : "▼"} | ||
| </span> | ||
| </button> | ||
| <div className={`max-h-64 overflow-y-auto space-y-0.5 mt-3 ${isMobile && !isMoveHistoryOpen ? "hidden" : ""}`}> |
There was a problem hiding this comment.
Toggle button is focusable & clickable on desktop but does nothing.
onClick={() => isMobile && setIsMoveHistoryOpen(...)} makes the entire header act as a button on every viewport, but the click is a no-op on lg+. Users navigating by keyboard get a focus ring on a control that produces no effect. Either render a plain <h3> on desktop and the button only on mobile, or short-circuit interactivity via the element itself.
Suggested fix
- <button
- className="flex items-center justify-between w-full text-left"
- onClick={() => isMobile && setIsMoveHistoryOpen((prev) => !prev)}
- aria-expanded={isMobile ? isMoveHistoryOpen : true}
- >
- <h3 className="text-sm font-semibold text-gray-300">Moves</h3>
- <span className="text-gray-500 text-xs lg:hidden">
- {isMoveHistoryOpen ? "▲" : "▼"}
- </span>
- </button>
+ <button
+ type="button"
+ className="flex items-center justify-between w-full text-left lg:cursor-default lg:pointer-events-none"
+ onClick={() => setIsMoveHistoryOpen((prev) => !prev)}
+ aria-expanded={isMoveHistoryOpen}
+ aria-controls="move-history-list"
+ >
+ <h3 className="text-sm font-semibold text-gray-300">Moves</h3>
+ <span className="text-gray-500 text-xs lg:hidden" aria-hidden="true">
+ {isMoveHistoryOpen ? "▲" : "▼"}
+ </span>
+ </button>(Then add id="move-history-list" to the list <div> at Line 366 and prefer pure CSS visibility — e.g., lg:!block — over the JS isMobile check to avoid hydration flicker.)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/app/play/`[slug]/page.tsx around lines 356 - 366, The header button
currently uses onClick={() => isMobile && setIsMoveHistoryOpen(...)} which makes
it focusable and clickable on desktop but a no-op; change this so the
interactive button is only rendered when mobile (use a conditional to render the
<button> with onClick that toggles isMoveHistoryOpen and render a plain <h3> for
desktop) or make the element non-interactive on desktop (remove button
role/handlers when !isMobile). Also add id="move-history-list" to the moves
container <div> and prefer CSS-driven visibility for desktop (e.g., ensure the
container uses a lg:!block or equivalent class so layout doesn’t rely on
isMobile during hydration). Ensure you update references to isMoveHistoryOpen
and setIsMoveHistoryOpen accordingly.
| const getSquareFromTouch = useCallback((touch: React.Touch): [number, number] | null => { | ||
| if (!boardRef.current) return null; | ||
| const rect = boardRef.current.getBoundingClientRect(); | ||
| const x = touch.clientX - rect.left; | ||
| const y = touch.clientY - rect.top; | ||
| const col = Math.floor((x / rect.width) * 8); | ||
| const row = Math.floor((y / rect.height) * 8); | ||
| if (col < 0 || col > 7 || row < 0 || row > 7) return null; | ||
| return [row, col]; | ||
| }, []); |
There was a problem hiding this comment.
Coordinate mapping ignores the container's 1% padding.
boardRef.current.getBoundingClientRect() returns the full container including the padding: "1%" set at Line 351, but the 8×8 grid cells only occupy the inner content box. Dividing (x / rect.width) * 8 therefore stretches the grid mapping across the padded area, so:
- touches landing inside the padding strip map to column/row 0 or 7 (outside the actual square boundary), and
- every square is slightly off-aligned from its visual edges, biasing larger errors toward the borders.
Subtract the padding (or read the inner cell rect) before computing indices.
Suggested fix
const getSquareFromTouch = useCallback((touch: React.Touch): [number, number] | null => {
if (!boardRef.current) return null;
- const rect = boardRef.current.getBoundingClientRect();
- const x = touch.clientX - rect.left;
- const y = touch.clientY - rect.top;
- const col = Math.floor((x / rect.width) * 8);
- const row = Math.floor((y / rect.height) * 8);
+ const el = boardRef.current;
+ const rect = el.getBoundingClientRect();
+ const cs = window.getComputedStyle(el);
+ const padX = parseFloat(cs.paddingLeft) || 0;
+ const padY = parseFloat(cs.paddingTop) || 0;
+ const innerW = rect.width - padX - (parseFloat(cs.paddingRight) || 0);
+ const innerH = rect.height - padY - (parseFloat(cs.paddingBottom) || 0);
+ const x = touch.clientX - rect.left - padX;
+ const y = touch.clientY - rect.top - padY;
+ const col = Math.floor((x / innerW) * 8);
+ const row = Math.floor((y / innerH) * 8);
if (col < 0 || col > 7 || row < 0 || row > 7) return null;
return [row, col];
}, []);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const getSquareFromTouch = useCallback((touch: React.Touch): [number, number] | null => { | |
| if (!boardRef.current) return null; | |
| const rect = boardRef.current.getBoundingClientRect(); | |
| const x = touch.clientX - rect.left; | |
| const y = touch.clientY - rect.top; | |
| const col = Math.floor((x / rect.width) * 8); | |
| const row = Math.floor((y / rect.height) * 8); | |
| if (col < 0 || col > 7 || row < 0 || row > 7) return null; | |
| return [row, col]; | |
| }, []); | |
| const getSquareFromTouch = useCallback((touch: React.Touch): [number, number] | null => { | |
| if (!boardRef.current) return null; | |
| const el = boardRef.current; | |
| const rect = el.getBoundingClientRect(); | |
| const cs = window.getComputedStyle(el); | |
| const padX = parseFloat(cs.paddingLeft) || 0; | |
| const padY = parseFloat(cs.paddingTop) || 0; | |
| const innerW = rect.width - padX - (parseFloat(cs.paddingRight) || 0); | |
| const innerH = rect.height - padY - (parseFloat(cs.paddingBottom) || 0); | |
| const x = touch.clientX - rect.left - padX; | |
| const y = touch.clientY - rect.top - padY; | |
| const col = Math.floor((x / innerW) * 8); | |
| const row = Math.floor((y / innerH) * 8); | |
| if (col < 0 || col > 7 || row < 0 || row > 7) return null; | |
| return [row, col]; | |
| }, []); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/components/chess/ChessboardComponent.tsx` around lines 278 - 287,
The touch-to-square mapping in getSquareFromTouch currently uses the full
bounding rect (boardRef and getBoundingClientRect) which includes the
container's 1% padding, causing incorrect col/row math; fix it by computing the
inner content box before mapping: read the board element's computed padding
(e.g., via getComputedStyle on boardRef.current) or calculate padding pixels
from rect.width/height and the known 1% value, subtract left/right and
top/bottom padding to build an innerRect (innerLeft, innerTop, innerWidth,
innerHeight), then compute x = touch.clientX - innerLeft and y = touch.clientY -
innerTop and derive col/row as Math.floor((x / innerWidth) * 8) and
Math.floor((y / innerHeight) * 8), returning null when outside 0–7; update
getSquareFromTouch to use this inner content rect.
| const handleTouchEnd = useCallback( | ||
| (e: React.TouchEvent) => { | ||
| if (!touchStartSquare.current) return; | ||
| const sq = getSquareFromTouch(e.changedTouches[0]); | ||
| if (sq) { | ||
| const [srcRow, srcCol] = touchStartSquare.current.split(",").map(Number); | ||
| attemptMove(srcRow, srcCol, sq[0], sq[1]); | ||
| } | ||
| touchStartSquare.current = null; | ||
| setHoveredSquare(null); | ||
| setSelectedSquare(null); | ||
| }, | ||
| [getSquareFromTouch, attemptMove], | ||
| ); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
fd ChessboardComponent.tsxRepository: NOVUS-X/XLMate
Length of output: 108
🏁 Script executed:
cat -n frontend/components/chess/ChessboardComponent.tsx | head -450 | tail -250Repository: NOVUS-X/XLMate
Length of output: 9930
🏁 Script executed:
cat -n frontend/components/chess/ChessboardComponent.tsx | sed -n '220,330p'Repository: NOVUS-X/XLMate
Length of output: 4177
🏁 Script executed:
cat -n frontend/components/chess/ChessboardComponent.tsx | sed -n '1,100p'Repository: NOVUS-X/XLMate
Length of output: 4301
🌐 Web query:
React TouchEvent synthetic click after touchend preventDefault mobile
💡 Result:
In React web apps on mobile browsers, a touch gesture (touchstart → optional touchmove → touchend) generates a synthetic click event ~300ms after touchend for compatibility. This can cause both onTouchEnd and onClick handlers to fire if both are attached to the same element. To prevent the synthetic click after handling touchend: 1. Call e.preventDefault in the onTouchEnd handler. This suppresses the subsequent click event across browsers. Example: function handleTouchEnd(e) { // Your touch logic here console.log('Touch ended'); e.preventDefault; // Prevents synthetic click } function handleClick(e) { // Only fires on real mouse clicks (desktop) console.log('Real click'); } return (
Citations:
- 1: touchstart preventDefault() does not prevent click event. react/react#9809
- 2: https://stackoverflow.com/questions/45612379/react-onclick-and-ontouchstart-fired-simultaneously
- 3: Unnecessary preventDefault() on touchend events? Splidejs/splide#625
- 4: https://16.reactjs.org/docs/events.html
- 5: https://reactdevelopers.org/docs/react-events/touch-events
- 6: https://stackoverflow.com/questions/6780965/why-onclick-event-suppressed-when-preventdefault-is-called-for-the-touchstart
- 7: Bug: preventDefault on
onTouchMoveis not preventing scrolling on iOS react/react#20999
🌐 Web query:
HTML5 touch events synthetic click on target square after touchend
💡 Result:
To generate a synthetic click event on the target element (e.g., a square) immediately after the native touchend event in HTML5, use the touchend event handler to create and dispatch a MouseEvent of type "click" on event.target. This mimics the browser's natural behavior where browsers automatically dispatch emulated mouse/click events after touchend for single-touch interactions, but allows custom control (e.g., immediate firing without 300ms delay, or after custom logic). ## Code Example javascript element.addEventListener('touchend', function(event) { // Optional: Check for single touch, no significant movement, etc. if (event.changedTouches.length === 1) { const touch = event.changedTouches[0]; // Create synthetic click event using modern MouseEvent constructor const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true, view: window, clientX: touch.clientX, clientY: touch.clientY, screenX: touch.screenX, screenY: touch.screenY, button: 0, buttons: 1 }); // Dispatch on the original target (the touched square) event.target.dispatchEvent(clickEvent); } }, { passive: false }); // passive: false to allow preventDefault if needed ## Key Points - Target: Use event.target from touchend (the square/element first touched). - Coordinates: Pull from event.changedTouches[0] for accurate position. - Why after touchend?: Matches browser timing; touchend fires when finger lifts, then click normally follows. - Prevent double-firing: If you want only your synthetic click (no browser's), call event.preventDefault in touchstart or touchend. - Browser behavior: Browsers auto-generate these synthetic clicks for compatibility (single touch → mousedown/mouseup/click). Your code replicates this manually. - Modern & Cross-browser: new MouseEvent works in all modern browsers (Chrome 41+, Firefox 45+, Safari 9+). For legacy, fallback to document.createEvent('MouseEvents') + initMouseEvent. This approach is commonly used to eliminate 300ms touch delay or implement custom tap handling on interactive elements like game squares/buttons.
Citations:
- 1: https://developer.mozilla.org/en-US/docs/Web/API/Touch_events/Using_Touch_Events
- 2: https://developer.mozilla.org/en-US/docs/DOM/MouseEvent
- 3: https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent.MouseEvent
- 4: https://stackoverflow.com/questions/5159806/how-do-i-synthesize-a-browser-click-event-on-a-div-element
- 5: https://gist.github.com/cagerton/7948779
Suppress synthetic click after touchend to prevent re-selecting the moved piece.
After a successful touch drag-and-drop, the browser automatically dispatches a synthetic click event on the target element for touch-to-click compatibility. Without intervention, this click fires and handleSquareClick (line 230) sees an empty selectedSquare (cleared at line 317) and a piece now occupying the target square, causing it to select the just-moved piece. The destination square displays the selection ring immediately after the move.
Calling e.preventDefault() on the touchend event suppresses the synthetic click on all modern browsers.
Suggested fix
const handleTouchEnd = useCallback(
(e: React.TouchEvent) => {
if (!touchStartSquare.current) return;
+ e.preventDefault();
const sq = getSquareFromTouch(e.changedTouches[0]);
if (sq) {
const [srcRow, srcCol] = touchStartSquare.current.split(",").map(Number);
attemptMove(srcRow, srcCol, sq[0], sq[1]);
}
touchStartSquare.current = null;
setHoveredSquare(null);
setSelectedSquare(null);
},
[getSquareFromTouch, attemptMove],
);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleTouchEnd = useCallback( | |
| (e: React.TouchEvent) => { | |
| if (!touchStartSquare.current) return; | |
| const sq = getSquareFromTouch(e.changedTouches[0]); | |
| if (sq) { | |
| const [srcRow, srcCol] = touchStartSquare.current.split(",").map(Number); | |
| attemptMove(srcRow, srcCol, sq[0], sq[1]); | |
| } | |
| touchStartSquare.current = null; | |
| setHoveredSquare(null); | |
| setSelectedSquare(null); | |
| }, | |
| [getSquareFromTouch, attemptMove], | |
| ); | |
| const handleTouchEnd = useCallback( | |
| (e: React.TouchEvent) => { | |
| if (!touchStartSquare.current) return; | |
| e.preventDefault(); | |
| const sq = getSquareFromTouch(e.changedTouches[0]); | |
| if (sq) { | |
| const [srcRow, srcCol] = touchStartSquare.current.split(",").map(Number); | |
| attemptMove(srcRow, srcCol, sq[0], sq[1]); | |
| } | |
| touchStartSquare.current = null; | |
| setHoveredSquare(null); | |
| setSelectedSquare(null); | |
| }, | |
| [getSquareFromTouch, attemptMove], | |
| ); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/components/chess/ChessboardComponent.tsx` around lines 307 - 320,
The touchend handler handleTouchEnd should call e.preventDefault() to suppress
the synthetic click that re-selects the moved piece: inside handleTouchEnd
(before/after computing sq) when a destination square is detected and you call
attemptMove, invoke e.preventDefault() so handleSquareClick does not receive a
synthetic click after the move; ensure touchStartSquare.current,
setSelectedSquare(null), and setHoveredSquare(null) logic remains unchanged so
state clearing still happens.
Summary
Enhances the play page and chessboard component for mobile UX.
Changes
components/chess/ChessboardComponent.tsxboardRefto compute square coordinates from touch positiontouchStartSquareref to track lifted piecehoveredSquarestate — renders teal inset ring on target square during dragonTouchStartper square,onTouchMove/onTouchEndon board containerapp/play/[slug]/page.tsxlg:hidden) directly below board — exposes Flip, Resign, and connection status without scrollinghidden lg:flex) unchanged for ≥1024pxisMoveHistoryOpenstate)Testing
Close #580
Summary by CodeRabbit
Release Notes