feat(practice): compact single-student categories and UI improvements

- Add compact display for single-student categories so they flow
  together on the same row instead of each taking a full row
- Hide "Present" (in-classroom) filter segment when no students present
- Update in-classroom empty state with bulk-prompt instructions
- Fix classroomId ReferenceError in ViewEmptyState component
- Add Storybook stories for GroupedCategories demonstrating
  compact/full rendering patterns
- Add compact mode to StudentSelector component

🤖 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-30 11:59:32 -06:00
parent 78a63e35e3
commit 0e7f3265fe
7 changed files with 1302 additions and 437 deletions

View File

@@ -6,6 +6,7 @@ import { useMutation } from '@tanstack/react-query'
import { useToast } from '@/components/common/ToastContext'
import {
AddStudentByFamilyCodeModal,
AddStudentToClassroomContent,
AddStudentToClassroomModal,
CreateClassroomForm,
PendingApprovalsSection,
@@ -524,6 +525,7 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
{filteredGroupedStudents.length === 0 && studentsNeedingAttention.length === 0 ? (
<ViewEmptyState
currentView={currentView}
classroomId={classroomId}
classroomCode={classroomCode}
searchQuery={searchQuery}
skillFilters={skillFilters}
@@ -565,7 +567,7 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
{bucket.bucketName}
</h2>
{/* Categories within bucket */}
{/* Categories within bucket - grouped for compact display */}
<div
className={css({
display: 'flex',
@@ -573,83 +575,168 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
gap: '16px',
})}
>
{bucket.categories.map((category) => {
const attentionCount =
attentionCountsByBucket.get(bucket.bucket)?.get(category.category) ?? 0
return (
<div
key={category.category ?? 'null'}
data-category={category.category ?? 'new'}
>
{/* Category header - sticky below bucket header */}
<h3
data-element="category-header"
className={css({
position: 'sticky',
top: '195px', // Nav (80px) + Filter bar (~80px) + Bucket header (~35px)
zIndex: Z_INDEX.STICKY_CATEGORY_HEADER,
fontSize: '0.8125rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
marginBottom: '8px',
paddingTop: '4px',
paddingBottom: '4px',
paddingLeft: '4px',
bg: isDark ? 'gray.900' : 'gray.50',
})}
>
{category.categoryName}
</h3>
{(() => {
// Group consecutive compact categories (1 student, no attention placeholder)
type RenderItem =
| { type: 'compact-row'; categories: typeof bucket.categories }
| { type: 'full'; category: (typeof bucket.categories)[0] }
{/* Student cards wrapper */}
const items: RenderItem[] = []
let compactBuffer: typeof bucket.categories = []
for (const cat of bucket.categories) {
const attentionCount =
attentionCountsByBucket.get(bucket.bucket)?.get(cat.category) ?? 0
const isCompact = cat.students.length === 1 && attentionCount === 0
if (isCompact) {
compactBuffer.push(cat)
} else {
if (compactBuffer.length > 0) {
items.push({ type: 'compact-row', categories: compactBuffer })
compactBuffer = []
}
items.push({ type: 'full', category: cat })
}
}
if (compactBuffer.length > 0) {
items.push({ type: 'compact-row', categories: compactBuffer })
}
return items.map((item, idx) => {
if (item.type === 'compact-row') {
// Render compact categories flowing together
return (
<div
key={`compact-${idx}`}
data-element="compact-category-row"
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '12px',
alignItems: 'flex-start',
})}
>
{item.categories.map((cat) => (
<div
key={cat.category ?? 'null'}
data-category={cat.category ?? 'new'}
data-compact="true"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '4px',
})}
>
{/* Small inline category label */}
<span
data-element="compact-category-label"
className={css({
fontSize: '0.75rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
paddingLeft: '4px',
})}
>
{cat.categoryName}
</span>
{/* Single student tile */}
<StudentSelector
students={cat.students as StudentWithProgress[]}
onSelectStudent={handleSelectStudent}
onToggleSelection={handleToggleSelection}
onObserveSession={handleObserveSession}
title=""
selectedIds={selectedIds}
hideAddButton
compact
/>
</div>
))}
</div>
)
}
// Render full category (2+ students or has attention placeholder)
const category = item.category
const attentionCount =
attentionCountsByBucket.get(bucket.bucket)?.get(category.category) ?? 0
return (
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'stretch',
})}
key={category.category ?? 'null'}
data-category={category.category ?? 'new'}
>
{/* Student cards */}
{category.students.length > 0 && (
<StudentSelector
students={category.students as StudentWithProgress[]}
onSelectStudent={handleSelectStudent}
onToggleSelection={handleToggleSelection}
onObserveSession={handleObserveSession}
title=""
selectedIds={selectedIds}
hideAddButton
/>
)}
{/* Category header - sticky below bucket header */}
<h3
data-element="category-header"
className={css({
position: 'sticky',
top: '195px', // Nav (80px) + Filter bar (~80px) + Bucket header (~35px)
zIndex: Z_INDEX.STICKY_CATEGORY_HEADER,
fontSize: '0.8125rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
marginBottom: '8px',
paddingTop: '4px',
paddingBottom: '4px',
paddingLeft: '4px',
bg: isDark ? 'gray.900' : 'gray.50',
})}
>
{category.categoryName}
</h3>
{/* Attention placeholder */}
{attentionCount > 0 && (
<div
data-element="attention-placeholder"
data-attention-count={attentionCount}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '12px 16px',
borderRadius: '8px',
border: '2px dashed',
borderColor: isDark ? 'orange.700' : 'orange.300',
color: isDark ? 'orange.400' : 'orange.600',
fontSize: '0.8125rem',
textAlign: 'center',
minHeight: '60px',
flexShrink: 0,
})}
>
+{attentionCount} in Needs Attention
</div>
)}
{/* Student cards wrapper */}
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'stretch',
})}
>
{/* Student cards */}
{category.students.length > 0 && (
<StudentSelector
students={category.students as StudentWithProgress[]}
onSelectStudent={handleSelectStudent}
onToggleSelection={handleToggleSelection}
onObserveSession={handleObserveSession}
title=""
selectedIds={selectedIds}
hideAddButton
/>
)}
{/* Attention placeholder */}
{attentionCount > 0 && (
<div
data-element="attention-placeholder"
data-attention-count={attentionCount}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '12px 16px',
borderRadius: '8px',
border: '2px dashed',
borderColor: isDark ? 'orange.700' : 'orange.300',
color: isDark ? 'orange.400' : 'orange.600',
fontSize: '0.8125rem',
textAlign: 'center',
minHeight: '60px',
flexShrink: 0,
})}
>
+{attentionCount} in Needs Attention
</div>
)}
</div>
</div>
</div>
)
})}
)
})
})()}
</div>
</div>
))}
@@ -767,6 +854,7 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
*/
interface ViewEmptyStateProps {
currentView: StudentView
classroomId?: string
classroomCode?: string
searchQuery: string
skillFilters: string[]
@@ -777,6 +865,7 @@ interface ViewEmptyStateProps {
function ViewEmptyState({
currentView,
classroomId,
classroomCode,
searchQuery,
skillFilters,
@@ -830,7 +919,7 @@ function ViewEmptyState({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '16px',
gap: '24px',
})}
>
<div
@@ -854,31 +943,50 @@ function ViewEmptyState({
<p
className={css({
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '16px',
})}
>
Students can enter your classroom using the code below
Enrolled students can enter via their practice page, or you can prompt them to join.
</p>
</div>
{classroomCode && (
<div
{/* Instructions for bulk prompt */}
<div
className={css({
backgroundColor: isDark ? 'gray.800' : 'gray.50',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
borderRadius: '12px',
padding: '16px 20px',
maxWidth: '400px',
textAlign: 'left',
})}
>
<p
className={css({
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '12px 20px',
backgroundColor: isDark ? 'gray.800' : 'gray.100',
borderRadius: '8px',
fontSize: '1.5rem',
fontWeight: 'bold',
fontFamily: 'monospace',
letterSpacing: '0.2em',
color: isDark ? 'blue.400' : 'blue.600',
fontSize: '0.875rem',
fontWeight: 'medium',
color: isDark ? 'gray.300' : 'gray.600',
marginBottom: '8px',
})}
>
{classroomCode}
</div>
)}
To prompt students to enter:
</p>
<ol
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.500',
paddingLeft: '20px',
margin: 0,
display: 'flex',
flexDirection: 'column',
gap: '4px',
})}
>
<li>Switch to the "Enrolled" view above</li>
<li>Select students using the checkboxes</li>
<li>Click "Prompt to Enter" in the selection bar</li>
</ol>
</div>
</div>
)
@@ -888,67 +996,45 @@ function ViewEmptyState({
data-element="empty-state"
data-reason="no-enrolled-students"
className={css({
textAlign: 'center',
padding: '3rem',
padding: '2rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '16px',
gap: '24px',
maxWidth: '700px',
margin: '0 auto',
})}
>
<div
className={css({
fontSize: '3rem',
})}
>
📋
</div>
<div>
<div className={css({ textAlign: 'center' })}>
<div
className={css({
fontSize: '2.5rem',
marginBottom: '8px',
})}
>
📋
</div>
<h3
className={css({
fontSize: '1.25rem',
fontWeight: 'semibold',
color: isDark ? 'gray.200' : 'gray.700',
marginBottom: '8px',
marginBottom: '4px',
})}
>
No enrolled students
No enrolled students yet
</h3>
<p
className={css({
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '8px',
fontSize: '0.9375rem',
})}
>
Parents can enroll their children using your classroom code
</p>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.500' : 'gray.400',
})}
>
Or you can add students directly using their family code
Add your first student to get started
</p>
</div>
{classroomCode && (
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '12px 20px',
backgroundColor: isDark ? 'gray.800' : 'gray.100',
borderRadius: '8px',
fontSize: '1.5rem',
fontWeight: 'bold',
fontFamily: 'monospace',
letterSpacing: '0.2em',
color: isDark ? 'blue.400' : 'blue.600',
})}
>
{classroomCode}
</div>
{classroomId && classroomCode && (
<AddStudentToClassroomContent classroomId={classroomId} classroomCode={classroomCode} />
)}
</div>
)

View File

@@ -37,30 +37,25 @@ interface PlayerPreview {
type LeftColumnMode = 'choose' | 'create' | 'family-success'
interface AddStudentToClassroomModalProps {
isOpen: boolean
onClose: () => void
export interface AddStudentToClassroomContentProps {
classroomId: string
classroomName: string
classroomCode: string
/** Optional callback when a student is successfully added (for modal close) */
onStudentAdded?: () => void
}
/**
* Unified modal for adding students to a classroom.
* Content for adding students to a classroom - used both in modal and inline.
*
* Two columns:
* - Left: "ADD NOW" - Create student form OR enter family code
* - Right: "INVITE PARENTS" - Share classroom code with QR
*
* The create student form is integrated inline (not a separate modal).
*/
export function AddStudentToClassroomModal({
isOpen,
onClose,
export function AddStudentToClassroomContent({
classroomId,
classroomName,
classroomCode,
}: AddStudentToClassroomModalProps) {
onStudentAdded,
}: AddStudentToClassroomContentProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@@ -86,19 +81,11 @@ export function AddStudentToClassroomModal({
// Share code hook for the classroom
const shareCode = useShareCode({ type: 'classroom', code: classroomCode })
// Reset state when modal opens
// Initialize with random emoji/color
useEffect(() => {
if (isOpen) {
setLeftMode('choose')
setFormName('')
setFormEmoji(PLAYER_EMOJIS[Math.floor(Math.random() * PLAYER_EMOJIS.length)])
setFormColor(AVAILABLE_COLORS[Math.floor(Math.random() * AVAILABLE_COLORS.length)])
setShowEmojiPicker(false)
setFamilyCode('')
setFamilyCodeError(null)
setEnrolledPlayer(null)
}
}, [isOpen])
setFormEmoji(PLAYER_EMOJIS[Math.floor(Math.random() * PLAYER_EMOJIS.length)])
setFormColor(AVAILABLE_COLORS[Math.floor(Math.random() * AVAILABLE_COLORS.length)])
}, [])
const handleFamilyCodeSubmit = useCallback(async () => {
if (!familyCode.trim()) {
@@ -148,21 +135,17 @@ export function AddStudentToClassroomModal({
{ classroomId, playerId: player.id },
{
onSettled: () => {
onClose()
onStudentAdded?.()
},
}
)
} else {
onClose()
onStudentAdded?.()
}
},
}
)
}, [formName, formEmoji, formColor, createPlayer, classroomId, directEnroll, onClose])
const handleClose = useCallback(() => {
onClose()
}, [onClose])
}, [formName, formEmoji, formColor, createPlayer, classroomId, directEnroll, onStudentAdded])
const handleBackToChoose = useCallback(() => {
setLeftMode('choose')
@@ -199,7 +182,295 @@ export function AddStudentToClassroomModal({
}
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<div
data-component="add-student-to-classroom-content"
className={css({
display: 'grid',
gridTemplateColumns: { base: '1fr', md: '1fr 1fr' },
gap: '0',
borderRadius: '12px',
overflow: 'hidden',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
{/* Left column: ADD NOW */}
<div
data-section="add-now"
className={css({
padding: '20px',
borderRight: { base: 'none', md: '1px solid' },
borderBottom: { base: '1px solid', md: 'none' },
borderColor: isDark ? 'gray.700' : 'gray.200',
backgroundColor: isDark ? 'gray.800' : 'white',
})}
>
<h3
className={css({
fontSize: '0.75rem',
fontWeight: 'bold',
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '16px',
})}
>
Add Now
</h3>
{leftMode === 'choose' && (
<ChooseSection
onCreateStudent={handleStartCreate}
familyCode={familyCode}
onFamilyCodeChange={(val) => {
setFamilyCode(val)
setFamilyCodeError(null)
}}
onFamilyCodeSubmit={handleFamilyCodeSubmit}
isSubmittingFamilyCode={isSubmittingFamilyCode}
familyCodeError={familyCodeError}
isDark={isDark}
/>
)}
{leftMode === 'create' && (
<CreateStudentSection
name={formName}
onNameChange={setFormName}
emoji={formEmoji}
onEmojiClick={() => setShowEmojiPicker(true)}
color={formColor}
onColorChange={setFormColor}
onSubmit={handleCreateStudentSubmit}
onCancel={handleBackToChoose}
isPending={isPending}
isDark={isDark}
/>
)}
{leftMode === 'family-success' && enrolledPlayer && (
<FamilyCodeSuccess
player={enrolledPlayer}
isDark={isDark}
onAddAnother={handleBackToChoose}
onDone={onStudentAdded}
/>
)}
</div>
{/* Right column: INVITE PARENTS */}
<div
data-section="invite-parents"
className={css({
padding: '20px',
backgroundColor: isDark ? 'gray.750' : 'gray.50',
})}
>
<h3
className={css({
fontSize: '0.75rem',
fontWeight: 'bold',
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '16px',
})}
>
Invite Parents
</h3>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.300' : 'gray.600',
marginBottom: '16px',
lineHeight: '1.5',
})}
>
Share this code with parents. They'll enter it to request enrollment, and you'll approve
each request.
</p>
{/* QR Code */}
<div
data-element="qr-code-container"
className={css({
display: 'flex',
justifyContent: 'center',
padding: '16px',
backgroundColor: 'white',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
marginBottom: '16px',
})}
>
<AbacusQRCode value={shareCode.shareUrl} size={160} />
</div>
{/* Code display + copy buttons */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '8px',
})}
>
{/* Code button */}
<button
type="button"
onClick={shareCode.copyCode}
data-action="copy-code"
data-status={shareCode.codeCopied ? 'copied' : 'idle'}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
padding: '12px 16px',
backgroundColor: shareCode.codeCopied
? isDark
? 'green.900/60'
: 'green.50'
: isDark
? 'purple.900/60'
: 'purple.50',
border: '2px solid',
borderColor: shareCode.codeCopied
? isDark
? 'green.700'
: 'green.300'
: isDark
? 'purple.700'
: 'purple.300',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: {
backgroundColor: shareCode.codeCopied
? isDark
? 'green.800/60'
: 'green.100'
: isDark
? 'purple.800/60'
: 'purple.100',
},
})}
>
<span
className={css({
fontSize: '1.125rem',
fontFamily: 'monospace',
fontWeight: 'bold',
letterSpacing: '0.1em',
color: shareCode.codeCopied
? isDark
? 'green.300'
: 'green.700'
: isDark
? 'purple.300'
: 'purple.700',
})}
>
{shareCode.codeCopied ? '✓ Copied!' : classroomCode}
</span>
{!shareCode.codeCopied && (
<span
className={css({
fontSize: '0.75rem',
color: isDark ? 'purple.400' : 'purple.500',
})}
>
Copy
</span>
)}
</button>
{/* Link button */}
<button
type="button"
onClick={shareCode.copyLink}
data-action="copy-link"
data-status={shareCode.linkCopied ? 'copied' : 'idle'}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
padding: '10px 16px',
backgroundColor: shareCode.linkCopied
? isDark
? 'green.900/60'
: 'green.50'
: isDark
? 'blue.900/60'
: 'blue.50',
border: '1px solid',
borderColor: shareCode.linkCopied
? isDark
? 'green.700'
: 'green.300'
: isDark
? 'blue.700'
: 'blue.300',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: {
backgroundColor: shareCode.linkCopied
? isDark
? 'green.800/60'
: 'green.100'
: isDark
? 'blue.800/60'
: 'blue.100',
},
})}
>
<span
className={css({
fontSize: '0.875rem',
color: shareCode.linkCopied
? isDark
? 'green.300'
: 'green.700'
: isDark
? 'blue.300'
: 'blue.700',
})}
>
{shareCode.linkCopied ? '✓ Link copied!' : '🔗 Copy link to share'}
</span>
</button>
</div>
</div>
</div>
)
}
interface AddStudentToClassroomModalProps {
isOpen: boolean
onClose: () => void
classroomId: string
classroomName: string
classroomCode: string
}
/**
* Modal wrapper for AddStudentToClassroomContent.
*/
export function AddStudentToClassroomModal({
isOpen,
onClose,
classroomId,
classroomName,
classroomCode,
}: AddStudentToClassroomModalProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Dialog.Portal>
<Dialog.Overlay
className={css({
@@ -276,264 +547,13 @@ export function AddStudentToClassroomModal({
</Dialog.Close>
</div>
{/* Two-column content */}
<div
data-element="modal-content"
className={css({
display: 'grid',
gridTemplateColumns: { base: '1fr', md: '1fr 1fr' },
gap: '0',
})}
>
{/* Left column: ADD NOW */}
<div
data-section="add-now"
className={css({
padding: '20px',
borderRight: { base: 'none', md: '1px solid' },
borderBottom: { base: '1px solid', md: 'none' },
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<h3
className={css({
fontSize: '0.75rem',
fontWeight: 'bold',
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '16px',
})}
>
Add Now
</h3>
{leftMode === 'choose' && (
<ChooseSection
onCreateStudent={handleStartCreate}
familyCode={familyCode}
onFamilyCodeChange={(val) => {
setFamilyCode(val)
setFamilyCodeError(null)
}}
onFamilyCodeSubmit={handleFamilyCodeSubmit}
isSubmittingFamilyCode={isSubmittingFamilyCode}
familyCodeError={familyCodeError}
isDark={isDark}
/>
)}
{leftMode === 'create' && (
<CreateStudentSection
name={formName}
onNameChange={setFormName}
emoji={formEmoji}
onEmojiClick={() => setShowEmojiPicker(true)}
color={formColor}
onColorChange={setFormColor}
onSubmit={handleCreateStudentSubmit}
onCancel={handleBackToChoose}
isPending={isPending}
isDark={isDark}
/>
)}
{leftMode === 'family-success' && enrolledPlayer && (
<FamilyCodeSuccess
player={enrolledPlayer}
isDark={isDark}
onAddAnother={handleBackToChoose}
onDone={handleClose}
/>
)}
</div>
{/* Right column: INVITE PARENTS */}
<div
data-section="invite-parents"
className={css({
padding: '20px',
backgroundColor: isDark ? 'gray.750' : 'gray.50',
})}
>
<h3
className={css({
fontSize: '0.75rem',
fontWeight: 'bold',
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '16px',
})}
>
Invite Parents
</h3>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.300' : 'gray.600',
marginBottom: '16px',
lineHeight: '1.5',
})}
>
Share this code with parents. They'll enter it to request enrollment, and you'll
approve each request.
</p>
{/* QR Code */}
<div
data-element="qr-code-container"
className={css({
display: 'flex',
justifyContent: 'center',
padding: '16px',
backgroundColor: 'white',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
marginBottom: '16px',
})}
>
<AbacusQRCode value={shareCode.shareUrl} size={160} />
</div>
{/* Code display + copy buttons */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '8px',
})}
>
{/* Code button */}
<button
type="button"
onClick={shareCode.copyCode}
data-action="copy-code"
data-status={shareCode.codeCopied ? 'copied' : 'idle'}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
padding: '12px 16px',
backgroundColor: shareCode.codeCopied
? isDark
? 'green.900/60'
: 'green.50'
: isDark
? 'purple.900/60'
: 'purple.50',
border: '2px solid',
borderColor: shareCode.codeCopied
? isDark
? 'green.700'
: 'green.300'
: isDark
? 'purple.700'
: 'purple.300',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: {
backgroundColor: shareCode.codeCopied
? isDark
? 'green.800/60'
: 'green.100'
: isDark
? 'purple.800/60'
: 'purple.100',
},
})}
>
<span
className={css({
fontSize: '1.125rem',
fontFamily: 'monospace',
fontWeight: 'bold',
letterSpacing: '0.1em',
color: shareCode.codeCopied
? isDark
? 'green.300'
: 'green.700'
: isDark
? 'purple.300'
: 'purple.700',
})}
>
{shareCode.codeCopied ? '✓ Copied!' : classroomCode}
</span>
{!shareCode.codeCopied && (
<span
className={css({
fontSize: '0.75rem',
color: isDark ? 'purple.400' : 'purple.500',
})}
>
Copy
</span>
)}
</button>
{/* Link button */}
<button
type="button"
onClick={shareCode.copyLink}
data-action="copy-link"
data-status={shareCode.linkCopied ? 'copied' : 'idle'}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
padding: '10px 16px',
backgroundColor: shareCode.linkCopied
? isDark
? 'green.900/60'
: 'green.50'
: isDark
? 'blue.900/60'
: 'blue.50',
border: '1px solid',
borderColor: shareCode.linkCopied
? isDark
? 'green.700'
: 'green.300'
: isDark
? 'blue.700'
: 'blue.300',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: {
backgroundColor: shareCode.linkCopied
? isDark
? 'green.800/60'
: 'green.100'
: isDark
? 'blue.800/60'
: 'blue.100',
},
})}
>
<span
className={css({
fontSize: '0.875rem',
color: shareCode.linkCopied
? isDark
? 'green.300'
: 'green.700'
: isDark
? 'blue.300'
: 'blue.700',
})}
>
{shareCode.linkCopied ? '✓ Link copied!' : '🔗 Copy link to share'}
</span>
</button>
</div>
</div>
{/* Content - no border since modal provides it */}
<div className={css({ padding: '20px' })}>
<AddStudentToClassroomContent
classroomId={classroomId}
classroomCode={classroomCode}
onStudentAdded={onClose}
/>
</div>
</Dialog.Content>
</Dialog.Portal>

View File

@@ -1,5 +1,8 @@
export { AddStudentByFamilyCodeModal } from './AddStudentByFamilyCodeModal'
export { AddStudentToClassroomModal } from './AddStudentToClassroomModal'
export {
AddStudentToClassroomModal,
AddStudentToClassroomContent,
} from './AddStudentToClassroomModal'
export { ClassroomCodeShare } from './ClassroomCodeShare'
export { CreateClassroomForm } from './CreateClassroomForm'
export { EnrollChildFlow } from './EnrollChildFlow'

View File

@@ -0,0 +1,624 @@
import type { Meta, StoryObj } from '@storybook/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { css } from '../../../styled-system/css'
import { StudentSelector, type StudentWithProgress } from './StudentSelector'
// Mock router for Next.js navigation
const mockRouter = {
push: (url: string) => console.log('Router push:', url),
refresh: () => console.log('Router refresh'),
}
// Create a fresh query client for stories
function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Infinity,
},
},
})
}
/**
* Demonstrates the grouped category layout for the practice page.
*
* The practice page organizes students by:
* - **Buckets** (recency): "Today", "This Week", "Older", "New"
* - **Categories** (skills): "Five Complements (Addition)", "Ten Complements (Subtraction)", etc.
*
* Categories are displayed differently based on their content:
* - **Compact**: Categories with 1 student (and no attention placeholder) flow together on the same row
* - **Full**: Categories with 2+ students or attention placeholders get full-width sticky headers
*/
const meta: Meta = {
title: 'Practice/GroupedCategories',
parameters: {
layout: 'padded',
nextjs: {
appDirectory: true,
navigation: {
push: mockRouter.push,
},
},
},
decorators: [
(Story) => (
<QueryClientProvider client={createQueryClient()}>
<Story />
</QueryClientProvider>
),
],
tags: ['autodocs'],
}
export default meta
type Story = StoryObj
// Sample student data
const createStudent = (
id: string,
name: string,
emoji: string,
color: string,
level?: number
): StudentWithProgress => ({
id,
name,
emoji,
color,
currentLevel: level,
masteryPercent: level ? level * 25 : undefined,
createdAt: new Date(),
})
const students = {
sonia: createStudent('1', 'Sonia', '🦋', '#FFE4E1', 3),
marcus: createStudent('2', 'Marcus', '🦖', '#E0FFE0', 2),
luna: createStudent('3', 'Luna', '🌙', '#E0E0FF', 1),
alex: createStudent('4', 'Alex', '🚀', '#FFF0E0', 2),
maya: createStudent('5', 'Maya', '🌸', '#FFE0F0', 3),
kai: createStudent('6', 'Kai', '🐻', '#E0F0FF', 1),
}
interface CategoryData {
category: string
categoryName: string
students: StudentWithProgress[]
attentionCount?: number
}
interface BucketData {
bucket: string
bucketName: string
categories: CategoryData[]
}
// Component that replicates the exact structure from PracticeClient.tsx
function GroupedStudentsDemo({
buckets,
isDark = false,
}: {
buckets: BucketData[]
isDark?: boolean
}) {
return (
<div
className={css({
backgroundColor: isDark ? 'gray.900' : 'gray.50',
padding: '1.5rem',
borderRadius: '12px',
minWidth: '800px',
})}
>
<div
data-component="grouped-students"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '24px',
})}
>
{buckets.map((bucket) => (
<div key={bucket.bucket} data-bucket={bucket.bucket}>
{/* Bucket header (e.g., "OLDER", "TODAY") */}
<h2
data-element="bucket-header"
className={css({
fontSize: '0.875rem',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '12px',
paddingTop: '8px',
paddingBottom: '8px',
borderBottom: '2px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
{bucket.bucketName}
</h2>
{/* Categories within bucket - grouped for compact display */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '16px',
})}
>
{(() => {
// Group consecutive compact categories (1 student, no attention placeholder)
type RenderItem =
| { type: 'compact-row'; categories: CategoryData[] }
| { type: 'full'; category: CategoryData }
const items: RenderItem[] = []
let compactBuffer: CategoryData[] = []
for (const cat of bucket.categories) {
const isCompact = cat.students.length === 1 && (cat.attentionCount ?? 0) === 0
if (isCompact) {
compactBuffer.push(cat)
} else {
if (compactBuffer.length > 0) {
items.push({ type: 'compact-row', categories: compactBuffer })
compactBuffer = []
}
items.push({ type: 'full', category: cat })
}
}
if (compactBuffer.length > 0) {
items.push({ type: 'compact-row', categories: compactBuffer })
}
return items.map((item, idx) => {
if (item.type === 'compact-row') {
// Render compact categories flowing together
return (
<div
key={`compact-${idx}`}
data-element="compact-category-row"
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '12px',
alignItems: 'flex-start',
})}
>
{item.categories.map((cat) => (
<div
key={cat.category}
data-category={cat.category}
data-compact="true"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '4px',
})}
>
{/* Small inline category label */}
<span
data-element="compact-category-label"
className={css({
fontSize: '0.75rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
paddingLeft: '4px',
})}
>
{cat.categoryName}
</span>
{/* Single student tile */}
<StudentSelector
students={cat.students}
onSelectStudent={() => {}}
onToggleSelection={() => {}}
title=""
hideAddButton
compact
/>
</div>
))}
</div>
)
}
// Render full category (2+ students or has attention placeholder)
const category = item.category
return (
<div key={category.category} data-category={category.category}>
{/* Category header (sticky in real app) */}
<h3
data-element="category-header"
className={css({
fontSize: '0.8125rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
marginBottom: '8px',
paddingTop: '4px',
paddingBottom: '4px',
paddingLeft: '4px',
})}
>
{category.categoryName}
</h3>
{/* Student cards wrapper */}
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'stretch',
})}
>
{/* Student cards - NOT compact for multi-student */}
<StudentSelector
students={category.students}
onSelectStudent={() => {}}
onToggleSelection={() => {}}
title=""
hideAddButton
compact
/>
{/* Attention placeholder */}
{(category.attentionCount ?? 0) > 0 && (
<div
data-element="attention-placeholder"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '12px 16px',
borderRadius: '8px',
border: '2px dashed',
borderColor: isDark ? 'orange.700' : 'orange.300',
color: isDark ? 'orange.400' : 'orange.600',
fontSize: '0.8125rem',
textAlign: 'center',
minHeight: '120px',
minWidth: '150px',
})}
>
+{category.attentionCount} in Needs Attention
</div>
)}
</div>
</div>
)
})
})()}
</div>
</div>
))}
</div>
</div>
)
}
/**
* All single-student categories flow together on the same row
*/
export const AllCompact: Story = {
render: () => (
<GroupedStudentsDemo
buckets={[
{
bucket: 'older',
bucketName: 'Older',
categories: [
{
category: 'five-comp-sub',
categoryName: 'Five Complements (Subtraction)',
students: [students.sonia],
},
{
category: 'five-comp-add',
categoryName: 'Five Complements (Addition)',
students: [students.marcus],
},
{
category: 'ten-comp-sub',
categoryName: 'Ten Complements (Subtraction)',
students: [students.luna],
},
],
},
]}
/>
),
}
/**
* All categories have multiple students - each gets its own full-width header
*/
export const AllFull: Story = {
render: () => (
<GroupedStudentsDemo
buckets={[
{
bucket: 'older',
bucketName: 'Older',
categories: [
{
category: 'five-comp-sub',
categoryName: 'Five Complements (Subtraction)',
students: [students.sonia, students.marcus],
},
{
category: 'five-comp-add',
categoryName: 'Five Complements (Addition)',
students: [students.luna, students.alex, students.maya],
},
],
},
]}
/>
),
}
/**
* Mix of compact and full categories - compact ones flow together,
* full categories break the flow with their own header
*/
export const Mixed: Story = {
render: () => (
<GroupedStudentsDemo
buckets={[
{
bucket: 'older',
bucketName: 'Older',
categories: [
// Compact - flows with next compact
{
category: 'five-comp-sub',
categoryName: 'Five Complements (Subtraction)',
students: [students.sonia],
},
// Full - breaks flow, gets own header
{
category: 'five-comp-add',
categoryName: 'Five Complements (Addition)',
students: [students.marcus, students.luna],
},
// These two compact categories flow together
{
category: 'ten-comp-sub',
categoryName: 'Ten Complements (Subtraction)',
students: [students.alex],
},
{
category: 'ten-comp-add',
categoryName: 'Ten Complements (Addition)',
students: [students.maya],
},
],
},
]}
/>
),
}
/**
* Single student with attention placeholder - renders as full category
* because attention placeholder needs space
*/
export const SingleWithAttention: Story = {
render: () => (
<GroupedStudentsDemo
buckets={[
{
bucket: 'older',
bucketName: 'Older',
categories: [
// Compact
{
category: 'five-comp-sub',
categoryName: 'Five Complements (Subtraction)',
students: [students.sonia],
},
// Full (has attention placeholder even though only 1 student)
{
category: 'five-comp-add',
categoryName: 'Five Complements (Addition)',
students: [students.marcus],
attentionCount: 3,
},
// Compact
{
category: 'ten-comp-sub',
categoryName: 'Ten Complements (Subtraction)',
students: [students.luna],
},
],
},
]}
/>
),
}
/**
* Multiple buckets showing the full hierarchy
*/
export const MultipleBuckets: Story = {
render: () => (
<GroupedStudentsDemo
buckets={[
{
bucket: 'today',
bucketName: 'Today',
categories: [
{
category: 'five-comp-add',
categoryName: 'Five Complements (Addition)',
students: [students.sonia, students.marcus],
},
],
},
{
bucket: 'thisWeek',
bucketName: 'This Week',
categories: [
{
category: 'ten-comp-sub',
categoryName: 'Ten Complements (Subtraction)',
students: [students.luna],
},
{
category: 'ten-comp-add',
categoryName: 'Ten Complements (Addition)',
students: [students.alex],
},
],
},
{
bucket: 'older',
bucketName: 'Older',
categories: [
{ category: 'basic-add', categoryName: 'Basic Addition', students: [students.maya] },
{
category: 'basic-sub',
categoryName: 'Basic Subtraction',
students: [students.kai],
attentionCount: 2,
},
],
},
]}
/>
),
}
/**
* Many compact categories that wrap to multiple rows
*/
export const ManyCompactWrapping: Story = {
render: () => (
<GroupedStudentsDemo
buckets={[
{
bucket: 'older',
bucketName: 'Older',
categories: [
{
category: 'five-comp-sub',
categoryName: 'Five Comp (Sub)',
students: [students.sonia],
},
{
category: 'five-comp-add',
categoryName: 'Five Comp (Add)',
students: [students.marcus],
},
{ category: 'ten-comp-sub', categoryName: 'Ten Comp (Sub)', students: [students.luna] },
{ category: 'ten-comp-add', categoryName: 'Ten Comp (Add)', students: [students.alex] },
{ category: 'basic-add', categoryName: 'Basic Addition', students: [students.maya] },
{ category: 'basic-sub', categoryName: 'Basic Subtraction', students: [students.kai] },
],
},
]}
/>
),
}
/**
* Dark mode
*/
export const DarkMode: Story = {
render: () => (
<GroupedStudentsDemo
isDark
buckets={[
{
bucket: 'older',
bucketName: 'Older',
categories: [
{
category: 'five-comp-sub',
categoryName: 'Five Complements (Subtraction)',
students: [students.sonia],
},
{
category: 'five-comp-add',
categoryName: 'Five Complements (Addition)',
students: [students.marcus, students.luna],
},
{
category: 'ten-comp-sub',
categoryName: 'Ten Complements (Subtraction)',
students: [students.alex],
},
],
},
]}
/>
),
parameters: {
backgrounds: { default: 'dark' },
},
}
/**
* Realistic scenario with various category sizes across buckets
*/
export const RealisticScenario: Story = {
render: () => (
<GroupedStudentsDemo
buckets={[
{
bucket: 'today',
bucketName: 'Today',
categories: [
// Single compact
{
category: 'five-comp-sub',
categoryName: 'Five Complements (Subtraction)',
students: [students.sonia],
},
],
},
{
bucket: 'thisWeek',
bucketName: 'This Week',
categories: [
// Full with multiple students
{
category: 'five-comp-add',
categoryName: 'Five Complements (Addition)',
students: [students.marcus, students.luna, students.alex],
},
],
},
{
bucket: 'older',
bucketName: 'Older',
categories: [
// Two compact flow together
{
category: 'ten-comp-sub',
categoryName: 'Ten Complements (Sub)',
students: [students.maya],
},
{
category: 'ten-comp-add',
categoryName: 'Ten Complements (Add)',
students: [students.kai],
},
// Full with attention
{
category: 'basic-math',
categoryName: 'Basic Math',
students: [students.sonia],
attentionCount: 5,
},
],
},
]}
/>
),
}

View File

@@ -1,13 +1,45 @@
import type { Meta, StoryObj } from '@storybook/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { css } from '../../../styled-system/css'
import { StudentSelector, type StudentWithProgress } from './StudentSelector'
// Mock router for Next.js navigation
const mockRouter = {
push: (url: string) => console.log('Router push:', url),
refresh: () => console.log('Router refresh'),
}
// Create a fresh query client for stories
function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Infinity,
},
},
})
}
const meta: Meta<typeof StudentSelector> = {
title: 'Practice/StudentSelector',
component: StudentSelector,
parameters: {
layout: 'centered',
nextjs: {
appDirectory: true,
navigation: {
push: mockRouter.push,
},
},
},
decorators: [
(Story) => (
<QueryClientProvider client={createQueryClient()}>
<Story />
</QueryClientProvider>
),
],
tags: ['autodocs'],
}
@@ -131,3 +163,58 @@ export const StudentsWithoutProgress: Story = {
onSelectStudent: () => {},
},
}
/**
* Compact mode - renders cards without wrapper styling, for inline display
*/
export const CompactSingle: Story = {
args: {
students: [sampleStudents[0]],
onSelectStudent: () => {},
onToggleSelection: () => {},
compact: true,
hideAddButton: true,
},
decorators: [
(Story) => (
<div
className={css({
backgroundColor: 'gray.100',
padding: '1rem',
borderRadius: '8px',
})}
>
<Story />
</div>
),
],
}
/**
* Compact mode with multiple students - they render inline
*/
export const CompactMultiple: Story = {
args: {
students: sampleStudents,
onSelectStudent: () => {},
onToggleSelection: () => {},
compact: true,
hideAddButton: true,
},
decorators: [
(Story) => (
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
backgroundColor: 'gray.100',
padding: '1rem',
borderRadius: '8px',
})}
>
<Story />
</div>
),
],
}

View File

@@ -766,6 +766,8 @@ interface StudentSelectorProps {
onObserveSession?: (sessionId: string) => void
/** Enrollment actions (approve/deny) - shows buttons on cards with enrollmentRequestId */
enrollmentActions?: EnrollmentActions
/** Compact mode - minimal styling, no wrapper padding, for inline display */
compact?: boolean
}
/**
@@ -788,6 +790,7 @@ export function StudentSelector({
hideAddButton = false,
onObserveSession,
enrollmentActions,
compact = false,
}: StudentSelectorProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@@ -869,6 +872,38 @@ export function StudentSelector({
return () => window.removeEventListener('keydown', handleKeyDown)
}, [modalOpen, selectedStudent])
// Compact mode: just render cards inline without wrapper styling
if (compact) {
return (
<>
{students.map((student) => (
<StudentCard
key={student.id}
student={student}
onSelect={onSelectStudent}
onToggleSelection={onToggleSelection}
onOpenQuickLook={handleOpenQuickLook}
isSelected={selectedIds.has(student.id)}
onObserveSession={onObserveSession}
onRegisterRef={handleRegisterRef}
enrollmentActions={enrollmentActions}
/>
))}
{/* Student QuickLook/Notes Modal (unified with tabs) */}
{selectedStudent && (
<NotesModal
isOpen={modalOpen}
student={selectedStudent}
sourceBounds={sourceBounds}
onClose={handleCloseModal}
onObserveSession={onObserveSession}
/>
)}
</>
)
}
return (
<>
<div

View File

@@ -571,6 +571,8 @@ export function TeacherCompoundChip({
const isActiveActive = currentView === 'in-classroom-active'
const isAnyActive = isEnrolledActive || isInClassroomActive || isActiveActive
const hasInClassroomSegment =
availableViews.includes('in-classroom') && (viewCounts['in-classroom'] ?? 0) > 0
const hasActiveSegment =
availableViews.includes('in-classroom-active') && (viewCounts['in-classroom-active'] ?? 0) > 0
@@ -639,7 +641,7 @@ export function TeacherCompoundChip({
count={viewCounts['enrolled']}
onClick={() => onViewChange('enrolled')}
isDark={isDark}
position="first"
position={hasInClassroomSegment ? 'first' : 'last'}
isCompoundActive={isAnyActive}
colorScheme="blue"
embedded={embedded}
@@ -647,18 +649,20 @@ export function TeacherCompoundChip({
settingsTrigger={settingsTrigger}
/>
{/* In Classroom segment */}
<ChipSegment
config={inClassroomConfig}
isActive={isInClassroomActive}
count={viewCounts['in-classroom']}
onClick={() => onViewChange('in-classroom')}
isDark={isDark}
position={hasActiveSegment ? 'middle' : 'last'}
isCompoundActive={isAnyActive}
colorScheme="blue"
embedded={embedded}
/>
{/* In Classroom segment - only show if there are students present */}
{hasInClassroomSegment && (
<ChipSegment
config={inClassroomConfig}
isActive={isInClassroomActive}
count={viewCounts['in-classroom']}
onClick={() => onViewChange('in-classroom')}
isDark={isDark}
position={hasActiveSegment ? 'middle' : 'last'}
isCompoundActive={isAnyActive}
colorScheme="blue"
embedded={embedded}
/>
)}
{/* Active segment - only show if there are active sessions */}
{hasActiveSegment && (
@@ -825,6 +829,12 @@ export function getAvailableViews(
return count > 0
}
// In-classroom only appears when there are students present
if (v.id === 'in-classroom') {
const count = viewCounts?.['in-classroom'] ?? 0
return count > 0
}
return true
}).map((v) => v.id)
}