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:
Thomas Hallock 2025-12-26 21:16:58 -06:00
parent 576abcb89e
commit 0ba1551fea
7 changed files with 537 additions and 64 deletions

View File

@ -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])

View File

@ -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)

View File

@ -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>
)
}

View File

@ -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 && (

View File

@ -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}
/>
))}

View File

@ -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

View File

@ -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'