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:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
624
apps/web/src/components/practice/GroupedCategories.stories.tsx
Normal file
624
apps/web/src/components/practice/GroupedCategories.stories.tsx
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user