feat(practice): polish unified student list with keyboard nav and mobile UX
Completes Steps 14-18 of the unified student list plan: Step 14: Move edit mode to secondary menu - Replace prominent Edit button with ⋮ dropdown menu - Add "Select Multiple" option, "Done" button in edit mode Step 15-16: Clean up deprecated components - Remove ClassroomDashboard, ClassroomTab, StudentManagerTab exports - Add StudentQuickLook alias for NotesModal Step 17: Real-time updates - Add useClassroomSocket to PracticeClient for live updates - Teachers now see presence/session changes in real-time Step 18: Mobile responsive polish - ViewSelector: horizontal scroll on mobile, wrap on desktop - NotesModal: responsive height for virtual keyboard Bonus: Arrow key navigation in QuickLook modal - Left/Right/Up/Down arrows navigate between students - Uses bounding rects to find nearest card in direction - Disabled when editing notes (textarea focused) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
576abcb89e
commit
0ba1551fea
|
|
@ -8,6 +8,7 @@ import {
|
|||
PendingApprovalsSection,
|
||||
SessionObserverModal,
|
||||
} from '@/components/classroom'
|
||||
import { useClassroomSocket } from '@/hooks/useClassroomSocket'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import {
|
||||
getAvailableViews,
|
||||
|
|
@ -70,6 +71,10 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
|
|||
classroomId,
|
||||
} = useUnifiedStudents(initialPlayers)
|
||||
|
||||
// Real-time WebSocket updates for classroom events
|
||||
// This invalidates React Query caches when students enter/leave, sessions start/end, etc.
|
||||
useClassroomSocket(classroomId)
|
||||
|
||||
// View and filter state
|
||||
const availableViews = useMemo(() => getAvailableViews(isTeacher), [isTeacher])
|
||||
const defaultView = useMemo(() => getDefaultView(isTeacher), [isTeacher])
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
export { AddStudentByFamilyCodeModal } from './AddStudentByFamilyCodeModal'
|
||||
export { ClassroomCodeShare } from './ClassroomCodeShare'
|
||||
export { ClassroomDashboard } from './ClassroomDashboard'
|
||||
export { ClassroomTab } from './ClassroomTab'
|
||||
export { CreateClassroomForm } from './CreateClassroomForm'
|
||||
export { EnrollChildFlow } from './EnrollChildFlow'
|
||||
export { EnrollChildModal } from './EnrollChildModal'
|
||||
export { EnterClassroomButton } from './EnterClassroomButton'
|
||||
export { PendingApprovalsSection } from './PendingApprovalsSection'
|
||||
export { SessionObserverModal } from './SessionObserverModal'
|
||||
export { StudentManagerTab } from './StudentManagerTab'
|
||||
|
||||
// Deprecated - kept for reference but no longer exported:
|
||||
// - ClassroomDashboard (replaced by unified view in PracticeClient)
|
||||
// - ClassroomTab (functionality moved to unified student list)
|
||||
// - StudentManagerTab (functionality moved to unified student list)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import { animated, useSpring } from '@react-spring/web'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { EnrollChildModal } from '@/components/classroom'
|
||||
import { FamilyCodeDisplay } from '@/components/family'
|
||||
|
|
@ -113,12 +114,14 @@ function buildStudentActionData(student: StudentProp): StudentActionData {
|
|||
export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModalProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const router = useRouter()
|
||||
|
||||
// ========== Internal state (notes editing only) ==========
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editedNotes, setEditedNotes] = useState(student.notes ?? '')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isExpandingToFullscreen, setIsExpandingToFullscreen] = useState(false)
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
|
@ -131,6 +134,12 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
|
|||
const { data: curriculumData } = usePlayerCurriculumQuery(student.id)
|
||||
const updatePlayer = useUpdatePlayer() // For notes only
|
||||
|
||||
// ========== Navigation handler ==========
|
||||
const handleOpenDashboard = useCallback(() => {
|
||||
// Trigger expand-to-fullscreen animation, navigation happens onRest
|
||||
setIsExpandingToFullscreen(true)
|
||||
}, [])
|
||||
|
||||
// ========== Derived data ==========
|
||||
const relationship: StudentRelationship | null = student.relationship ?? null
|
||||
const activity: StudentActivity | null = student.activity ?? null
|
||||
|
|
@ -162,6 +171,7 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
|
|||
setEditedNotes(student.notes ?? '')
|
||||
setIsEditing(false)
|
||||
setActiveTab(defaultTab)
|
||||
setIsExpandingToFullscreen(false)
|
||||
}
|
||||
}, [isOpen, student.id, student.notes, defaultTab])
|
||||
|
||||
|
|
@ -201,8 +211,9 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
|
|||
const windowWidth = typeof window !== 'undefined' ? window.innerWidth : 800
|
||||
const windowHeight = typeof window !== 'undefined' ? window.innerHeight : 600
|
||||
|
||||
const modalWidth = Math.min(420, windowWidth - 40)
|
||||
const modalHeight = 400
|
||||
// Responsive modal dimensions
|
||||
const modalWidth = Math.min(420, windowWidth - 32)
|
||||
const modalHeight = Math.min(400, windowHeight - 100) // Leave room for virtual keyboard on mobile
|
||||
const targetX = (windowWidth - modalWidth) / 2
|
||||
const targetY = (windowHeight - modalHeight) / 2
|
||||
|
||||
|
|
@ -211,6 +222,44 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
|
|||
const sourceWidth = sourceBounds?.width ?? modalWidth
|
||||
const sourceHeight = sourceBounds?.height ?? modalHeight
|
||||
|
||||
// Determine animation target based on state
|
||||
const getAnimationTarget = () => {
|
||||
if (isExpandingToFullscreen) {
|
||||
// Expand to fullscreen
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
borderRadius: 0,
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
// Normal open state
|
||||
return {
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
width: modalWidth,
|
||||
height: modalHeight,
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
borderRadius: 16,
|
||||
}
|
||||
}
|
||||
// Closing - return to source
|
||||
return {
|
||||
x: sourceX,
|
||||
y: sourceY,
|
||||
width: sourceWidth,
|
||||
height: sourceHeight,
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
borderRadius: 16,
|
||||
}
|
||||
}
|
||||
|
||||
const springProps = useSpring({
|
||||
from: {
|
||||
x: sourceX,
|
||||
|
|
@ -219,17 +268,19 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
|
|||
height: sourceHeight,
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
borderRadius: 16,
|
||||
},
|
||||
to: {
|
||||
x: isOpen ? targetX : sourceX,
|
||||
y: isOpen ? targetY : sourceY,
|
||||
width: isOpen ? modalWidth : sourceWidth,
|
||||
height: isOpen ? modalHeight : sourceHeight,
|
||||
opacity: isOpen ? 1 : 0,
|
||||
scale: isOpen ? 1 : 0.95,
|
||||
},
|
||||
to: getAnimationTarget(),
|
||||
reset: isOpening,
|
||||
config: { tension: 300, friction: 30 },
|
||||
config: isExpandingToFullscreen
|
||||
? { tension: 400, friction: 30 } // Faster for fullscreen expand
|
||||
: { tension: 300, friction: 30 },
|
||||
onRest: () => {
|
||||
if (isExpandingToFullscreen) {
|
||||
// Navigate after animation completes
|
||||
router.push(`/practice/${student.id}/dashboard`)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const backdropSpring = useSpring({
|
||||
|
|
@ -292,17 +343,22 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
|
|||
transform: springProps.scale.to((s) => `scale(${s})`),
|
||||
transformOrigin: 'center center',
|
||||
zIndex: Z_INDEX.MODAL,
|
||||
pointerEvents: isOpen ? 'auto' : 'none',
|
||||
pointerEvents: isOpen || isExpandingToFullscreen ? 'auto' : 'none',
|
||||
borderRadius: springProps.borderRadius.to((r) => `${r}px`),
|
||||
}}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: '16px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)',
|
||||
boxShadow: isExpandingToFullscreen ? 'none' : '0 25px 50px -12px rgba(0, 0, 0, 0.5)',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
})}
|
||||
>
|
||||
{/* Show dashboard shell during expand transition */}
|
||||
{isExpandingToFullscreen ? (
|
||||
<DashboardShell student={student} isDark={isDark} />
|
||||
) : (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div
|
||||
data-section="header"
|
||||
|
|
@ -350,6 +406,31 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
|
|||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Open full dashboard button */}
|
||||
<button
|
||||
type="button"
|
||||
data-action="open-dashboard"
|
||||
onClick={handleOpenDashboard}
|
||||
title="Open full dashboard"
|
||||
className={css({
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
fontSize: '1rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
_hover: { backgroundColor: 'rgba(255, 255, 255, 0.3)' },
|
||||
})}
|
||||
>
|
||||
⛶
|
||||
</button>
|
||||
|
||||
{/* Overflow menu - uses shared actions hook */}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
|
|
@ -567,6 +648,8 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</animated.div>
|
||||
|
||||
{/* Sub-modals - managed by shared hook */}
|
||||
|
|
@ -1053,3 +1136,156 @@ function separatorStyle(isDark: boolean) {
|
|||
margin: '4px 0',
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dashboard Shell Component (shown during expand transition)
|
||||
// ============================================================================
|
||||
|
||||
interface DashboardShellProps {
|
||||
student: { name: string; emoji: string; color: string }
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A skeleton shell that matches the dashboard layout.
|
||||
* Shown during the expand-to-fullscreen transition for a seamless morph effect.
|
||||
*/
|
||||
function DashboardShell({ student, isDark }: DashboardShellProps) {
|
||||
return (
|
||||
<div
|
||||
data-component="dashboard-shell"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
backgroundColor: isDark ? 'gray.900' : 'gray.50',
|
||||
})}
|
||||
>
|
||||
{/* Nav bar shell */}
|
||||
<div
|
||||
className={css({
|
||||
height: '56px',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 16px',
|
||||
gap: '12px',
|
||||
})}
|
||||
>
|
||||
{/* Back button placeholder */}
|
||||
<div
|
||||
className={css({
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
/>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={css({
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1.25rem',
|
||||
})}
|
||||
style={{ backgroundColor: student.color }}
|
||||
>
|
||||
{student.emoji}
|
||||
</div>
|
||||
{/* Name */}
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: 'semibold',
|
||||
color: isDark ? 'gray.100' : 'gray.900',
|
||||
})}
|
||||
>
|
||||
{student.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sub-nav / tabs shell */}
|
||||
<div
|
||||
className={css({
|
||||
height: '48px',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 16px',
|
||||
gap: '24px',
|
||||
})}
|
||||
>
|
||||
{['Overview', 'Skills', 'History'].map((tab, i) => (
|
||||
<div
|
||||
key={tab}
|
||||
className={css({
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderBottom: '2px solid',
|
||||
borderColor: i === 0 ? (isDark ? 'blue.400' : 'blue.500') : 'transparent',
|
||||
color: i === 0 ? (isDark ? 'blue.400' : 'blue.600') : isDark ? 'gray.400' : 'gray.600',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: i === 0 ? 'semibold' : 'normal',
|
||||
})}
|
||||
>
|
||||
{tab}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content area with skeleton */}
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
{/* Stats cards skeleton */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||
gap: '16px',
|
||||
})}
|
||||
>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={css({
|
||||
height: '80px',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main content skeleton */}
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
borderRadius: '12px',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { Z_INDEX } from '@/constants/zIndex'
|
||||
|
|
@ -497,51 +498,107 @@ export function StudentFilterBar({
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Edit mode toggle button - always visible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEditModeChange(!editMode)}
|
||||
data-action="toggle-edit-mode"
|
||||
data-status={editMode ? 'editing' : 'viewing'}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 12px',
|
||||
bg: editMode ? (isDark ? 'amber.900' : 'amber.100') : isDark ? 'gray.700' : 'gray.100',
|
||||
border: '1px solid',
|
||||
borderColor: editMode
|
||||
? isDark
|
||||
? 'amber.700'
|
||||
: 'amber.300'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.300',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: editMode
|
||||
? isDark
|
||||
? 'amber.300'
|
||||
: 'amber.700'
|
||||
: isDark
|
||||
? 'gray.300'
|
||||
: 'gray.700',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
borderColor: editMode
|
||||
? isDark
|
||||
? 'amber.600'
|
||||
: 'amber.400'
|
||||
: isDark
|
||||
? 'gray.500'
|
||||
: 'gray.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>{editMode ? '✓' : '✏️'}</span>
|
||||
<span>{editMode ? 'Done' : 'Edit'}</span>
|
||||
</button>
|
||||
{/* More menu with edit mode and other options */}
|
||||
{editMode ? (
|
||||
/* Done button when in edit mode */
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEditModeChange(false)}
|
||||
data-action="done-editing"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 12px',
|
||||
bg: isDark ? 'amber.900' : 'amber.100',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'amber.700' : 'amber.300',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: isDark ? 'amber.300' : 'amber.700',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
borderColor: isDark ? 'amber.600' : 'amber.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>✓</span>
|
||||
<span>Done</span>
|
||||
</button>
|
||||
) : (
|
||||
/* More dropdown menu */
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-action="open-more-menu"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
bg: isDark ? 'gray.700' : 'gray.100',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '18px',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
bg: isDark ? 'gray.600' : 'gray.200',
|
||||
borderColor: isDark ? 'gray.500' : 'gray.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
className={css({
|
||||
minWidth: '180px',
|
||||
bg: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
boxShadow: 'lg',
|
||||
padding: '4px',
|
||||
zIndex: Z_INDEX.DROPDOWN,
|
||||
})}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
data-action="select-multiple"
|
||||
onSelect={() => onEditModeChange(true)}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
_hover: {
|
||||
bg: isDark ? 'gray.700' : 'gray.100',
|
||||
},
|
||||
_focus: {
|
||||
bg: isDark ? 'gray.700' : 'gray.100',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>☑️</span>
|
||||
<span>Select Multiple</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
|
||||
{/* Add Student FAB - only in normal mode */}
|
||||
{!editMode && onAddStudent && (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import * as Checkbox from '@radix-ui/react-checkbox'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import type { Player } from '@/types/player'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
|
@ -27,6 +27,78 @@ import {
|
|||
wrap,
|
||||
} from './styles'
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Find the next student in a given direction based on bounding rects.
|
||||
* Used for arrow key navigation in the QuickLook modal.
|
||||
*/
|
||||
function findNextStudent(
|
||||
currentId: string,
|
||||
direction: 'up' | 'down' | 'left' | 'right',
|
||||
cardRefs: Map<string, HTMLDivElement>
|
||||
): { studentId: string; bounds: DOMRect } | null {
|
||||
const currentRef = cardRefs.get(currentId)
|
||||
if (!currentRef) return null
|
||||
|
||||
const currentRect = currentRef.getBoundingClientRect()
|
||||
const currentCenterX = currentRect.left + currentRect.width / 2
|
||||
const currentCenterY = currentRect.top + currentRect.height / 2
|
||||
|
||||
let bestCandidate: { studentId: string; bounds: DOMRect; distance: number } | null = null
|
||||
|
||||
for (const [studentId, ref] of cardRefs) {
|
||||
if (studentId === currentId) continue
|
||||
|
||||
const rect = ref.getBoundingClientRect()
|
||||
const centerX = rect.left + rect.width / 2
|
||||
const centerY = rect.top + rect.height / 2
|
||||
|
||||
// Check if this card is in the correct direction
|
||||
let isValidDirection = false
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
isValidDirection = centerX < currentCenterX - 10
|
||||
break
|
||||
case 'right':
|
||||
isValidDirection = centerX > currentCenterX + 10
|
||||
break
|
||||
case 'up':
|
||||
isValidDirection = centerY < currentCenterY - 10
|
||||
break
|
||||
case 'down':
|
||||
isValidDirection = centerY > currentCenterY + 10
|
||||
break
|
||||
}
|
||||
|
||||
if (!isValidDirection) continue
|
||||
|
||||
// Calculate distance with preference for the primary direction
|
||||
let distance: number
|
||||
if (direction === 'left' || direction === 'right') {
|
||||
// Horizontal: prioritize same row (small Y difference)
|
||||
const yPenalty = Math.abs(centerY - currentCenterY) * 2
|
||||
distance = Math.abs(centerX - currentCenterX) + yPenalty
|
||||
} else {
|
||||
// Vertical: prioritize same column (small X difference)
|
||||
const xPenalty = Math.abs(centerX - currentCenterX) * 2
|
||||
distance = Math.abs(centerY - currentCenterY) + xPenalty
|
||||
}
|
||||
|
||||
if (!bestCandidate || distance < bestCandidate.distance) {
|
||||
bestCandidate = { studentId, bounds: rect, distance }
|
||||
}
|
||||
}
|
||||
|
||||
return bestCandidate ? { studentId: bestCandidate.studentId, bounds: bestCandidate.bounds } : null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Intervention data for students needing attention
|
||||
*/
|
||||
|
|
@ -59,6 +131,8 @@ interface StudentCardProps {
|
|||
isSelected?: boolean
|
||||
/** Callback when observe session is clicked */
|
||||
onObserveSession?: (sessionId: string) => void
|
||||
/** Callback to register card ref for keyboard navigation */
|
||||
onRegisterRef?: (studentId: string, ref: HTMLDivElement | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -72,6 +146,7 @@ function StudentCard({
|
|||
editMode,
|
||||
isSelected,
|
||||
onObserveSession,
|
||||
onRegisterRef,
|
||||
}: StudentCardProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
|
@ -79,6 +154,18 @@ function StudentCard({
|
|||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const isArchived = student.isArchived ?? false
|
||||
|
||||
// Register ref for keyboard navigation
|
||||
useEffect(() => {
|
||||
if (onRegisterRef && cardRef.current) {
|
||||
onRegisterRef(student.id, cardRef.current)
|
||||
}
|
||||
return () => {
|
||||
if (onRegisterRef) {
|
||||
onRegisterRef(student.id, null)
|
||||
}
|
||||
}
|
||||
}, [student.id, onRegisterRef])
|
||||
|
||||
// Check for unified student fields (relationship/activity) at runtime
|
||||
// These are present when using the unified student list
|
||||
const relationship = (
|
||||
|
|
@ -623,6 +710,27 @@ export function StudentSelector({
|
|||
const [selectedStudent, setSelectedStudent] = useState<StudentWithProgress | null>(null)
|
||||
const [sourceBounds, setSourceBounds] = useState<DOMRect | null>(null)
|
||||
|
||||
// Ref map for keyboard navigation
|
||||
const cardRefsRef = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||
|
||||
// Student lookup for quick access by ID
|
||||
const studentMap = useRef<Map<string, StudentWithProgress>>(new Map())
|
||||
useEffect(() => {
|
||||
studentMap.current.clear()
|
||||
for (const student of students) {
|
||||
studentMap.current.set(student.id, student)
|
||||
}
|
||||
}, [students])
|
||||
|
||||
// Register card ref callback
|
||||
const handleRegisterRef = useCallback((studentId: string, ref: HTMLDivElement | null) => {
|
||||
if (ref) {
|
||||
cardRefsRef.current.set(studentId, ref)
|
||||
} else {
|
||||
cardRefsRef.current.delete(studentId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleOpenQuickLook = useCallback((student: StudentWithProgress, bounds: DOMRect) => {
|
||||
setSelectedStudent(student)
|
||||
setSourceBounds(bounds)
|
||||
|
|
@ -633,6 +741,54 @@ export function StudentSelector({
|
|||
setModalOpen(false)
|
||||
}, [])
|
||||
|
||||
// Keyboard navigation when modal is open
|
||||
useEffect(() => {
|
||||
if (!modalOpen || !selectedStudent) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle arrow keys
|
||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return
|
||||
|
||||
// Don't interfere with text input (notes editing)
|
||||
if (
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLInputElement
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't interfere with dropdown menu navigation (Radix UI)
|
||||
if (
|
||||
e.target instanceof HTMLElement &&
|
||||
(e.target.closest('[data-radix-menu-content]') ||
|
||||
e.target.getAttribute('role') === 'menuitem' ||
|
||||
e.target.getAttribute('role') === 'menu')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
const direction = e.key.replace('Arrow', '').toLowerCase() as
|
||||
| 'up'
|
||||
| 'down'
|
||||
| 'left'
|
||||
| 'right'
|
||||
|
||||
const next = findNextStudent(selectedStudent.id, direction, cardRefsRef.current)
|
||||
if (next) {
|
||||
const nextStudent = studentMap.current.get(next.studentId)
|
||||
if (nextStudent) {
|
||||
setSelectedStudent(nextStudent)
|
||||
setSourceBounds(next.bounds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [modalOpen, selectedStudent])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
|
|
@ -671,6 +827,7 @@ export function StudentSelector({
|
|||
editMode={editMode}
|
||||
isSelected={selectedIds.has(student.id)}
|
||||
onObserveSession={onObserveSession}
|
||||
onRegisterRef={handleRegisterRef}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,8 +73,19 @@ export function ViewSelector({
|
|||
className={css({
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
// Mobile: horizontal scroll, Desktop: wrap
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
flexWrap: { base: 'nowrap', md: 'wrap' },
|
||||
// Hide scrollbar but allow scroll
|
||||
scrollbarWidth: 'none',
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
// Prevent text selection while swiping
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
// Add padding for mobile so chips don't touch edge when scrolling
|
||||
paddingRight: { base: '8px', md: 0 },
|
||||
})}
|
||||
>
|
||||
{availableViews.map((viewId) => {
|
||||
|
|
@ -102,6 +113,9 @@ export function ViewSelector({
|
|||
fontSize: '13px',
|
||||
fontWeight: 'medium',
|
||||
transition: 'all 0.15s ease',
|
||||
// Don't shrink on mobile scroll
|
||||
flexShrink: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
// Active state
|
||||
bg: isActive ? (isDark ? 'blue.900' : 'blue.100') : isDark ? 'gray.800' : 'white',
|
||||
borderColor: isActive
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export {
|
|||
useIsTouchDevice,
|
||||
} from './hooks/useDeviceDetection'
|
||||
export { NotesModal } from './NotesModal'
|
||||
// StudentQuickLook is an alias for NotesModal (which was enhanced to serve as the QuickLook modal)
|
||||
export { NotesModal as StudentQuickLook } from './NotesModal'
|
||||
export { NumericKeypad } from './NumericKeypad'
|
||||
export { PracticeErrorBoundary } from './PracticeErrorBoundary'
|
||||
export { PracticeFeedback } from './PracticeFeedback'
|
||||
|
|
|
|||
Loading…
Reference in New Issue