diff --git a/site/app/company/[slug]/SortableQuestionTable.tsx b/site/app/company/[slug]/SortableQuestionTable.tsx index 7c125f8..b69baaa 100644 --- a/site/app/company/[slug]/SortableQuestionTable.tsx +++ b/site/app/company/[slug]/SortableQuestionTable.tsx @@ -1,6 +1,8 @@ 'use client'; -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useRef } from 'react'; +import { createPortal } from 'react-dom'; + import DifficultyBadge from '@/components/DifficultyBadge'; import ProblemPanel from '@/components/ProblemPanel'; import type { Question } from '@/lib/types'; @@ -43,6 +45,8 @@ export default function SortableQuestionTable({ const [diffFilter, setDiffFilter] = useState>(new Set()); const [topicFilter, setTopicFilter] = useState>(new Set()); const [active, setActive] = useState(null); + const touchStart = useRef<{ x: number; y: number } | null>(null); + const touchMoved = useRef(false); const questionsWithTopics = useMemo( () => questions.map(q => ({ ...q, topics: getTopics(q.title) })), @@ -82,7 +86,14 @@ export default function SortableQuestionTable({ const closePanel = useCallback(() => setActive(null), []); + // Some mobile browsers do not reliably synthesize click events for table rows + // after a touch gesture. Activate the same action explicitly at touch end. + const activateQuestion = useCallback((q: QuestionWithTopics) => { + openPanel(q); + }, [openPanel]); + const surpriseMe = useCallback(() => { + if (visible.length === 0) return; const pick = visible[Math.floor(Math.random() * visible.length)]; setActive({ question: pick, maxFreq }); @@ -131,7 +142,13 @@ export default function SortableQuestionTable({ )} {/* Surprise Me — opens a random question from the current filtered list */} - @@ -156,9 +173,38 @@ export default function SortableQuestionTable({ openPanel(q)} + onClick={() => activateQuestion(q)} + onTouchStart={e => { + const touch = e.touches[0]; + touchStart.current = { x: touch.clientX, y: touch.clientY }; + touchMoved.current = false; + }} + onTouchMove={e => { + const start = touchStart.current; + const touch = e.touches[0]; + if (start && touch) { + const movedX = touch.clientX - start.x; + const movedY = touch.clientY - start.y; + if (Math.hypot(movedX, movedY) > 10) touchMoved.current = true; + } + }} + onTouchEnd={e => { + const wasScroll = touchMoved.current; + touchStart.current = null; + touchMoved.current = false; + if (!wasScroll) { + e.preventDefault(); + activateQuestion(q); + } + }} tabIndex={0} - onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') openPanel(q); }} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + activateQuestion(q); + } + }} + aria-label={`${q.title} — click to view details`} style={{ cursor: 'pointer' }} > @@ -198,8 +244,9 @@ export default function SortableQuestionTable({ )} - {/* Right-side problem panel */} - {active && ( + {/* Right-side problem panel. Render at document.body so a mobile + overflow/stacking context cannot clip or hide the fixed panel. */} + {active && typeof document !== 'undefined' && createPortal( + />, + document.body, )} + ); } diff --git a/site/app/globals.css b/site/app/globals.css index 70c9572..37f065a 100644 --- a/site/app/globals.css +++ b/site/app/globals.css @@ -575,7 +575,11 @@ button { color: inherit; } background: var(--csn-white); transition: background-color 120ms ease; } -.question-row { cursor: pointer; } +.question-row { + cursor: pointer; + touch-action: manipulation; +} + .question-row:hover { background: var(--paper-tint) !important; } .question-table tbody td { @@ -880,7 +884,9 @@ button { color: inherit; } /* ── Surprise Me button ──────────────────────────────────────────────────── */ .surprise-btn { + touch-action: manipulation; margin-left: auto; + padding: 0.3rem 0.8rem; border: 1.5px solid var(--csn-brass); border-radius: var(--radius); @@ -1053,9 +1059,12 @@ button { color: inherit; } .header-nav { grid-column: 1 / -1; grid-row: 2; justify-content: flex-start; padding: 0.4rem 0 0.75rem; border-top: 1px solid var(--rule-faint); } .search-wrap { grid-column: 2; } .featured-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .question-table thead th.th-freq, - .question-table tbody td.td-freq { display: none; } - .question-table thead th { top: 6.5rem; } + /* Keep all question metadata available on mobile. The table wrapper + provides horizontal scrolling when the full column set is wider than + the viewport. Header cells remain pinned to the wrapper, not the site + header, so rows cannot scroll behind them. */ + .question-table thead th { top: 0; } + } @media (max-width: 480px) {