diff --git a/apps/web/src/app/practice/PracticeClient.tsx b/apps/web/src/app/practice/PracticeClient.tsx index 4811fb2d..e3685102 100644 --- a/apps/web/src/app/practice/PracticeClient.tsx +++ b/apps/web/src/app/practice/PracticeClient.tsx @@ -1,7 +1,7 @@ 'use client' import { useRouter } from 'next/navigation' -import { useCallback, useMemo, useState } from 'react' +import { Fragment, useCallback, useMemo, useState } from 'react' import { useMutation } from '@tanstack/react-query' import { useToast } from '@/components/common/ToastContext' import { @@ -28,6 +28,7 @@ import { import { Z_INDEX } from '@/constants/zIndex' import { useTheme } from '@/contexts/ThemeContext' import { useMyClassroom } from '@/hooks/useClassroom' +import { type CompactItem, useMeasuredCompactLayout } from '@/hooks/useMeasuredCompactLayout' import { useParentSocket } from '@/hooks/useParentSocket' import { computeViewCounts, @@ -461,7 +462,7 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli {/* Shows for anyone with children, even if they're also a teacher */} - {/* All Students - unified layout with compact sections flowing together */} + {/* All Students - unified layout with measurement-based compact sections */} {filteredGroupedStudents.length === 0 && studentsNeedingAttention.length === 0 ? ( ) : ( -
- {(() => { - // Unified section type for both "Needs Attention" and regular buckets - type Section = - | { - type: 'attention' - students: typeof studentsNeedingAttention - } - | { - type: 'bucket' - bucket: (typeof filteredGroupedStudents)[0] - } - - // Build list of all sections (attention first, then buckets) - const allSections: Section[] = [] - if (studentsNeedingAttention.length > 0) { - allSections.push({ type: 'attention', students: studentsNeedingAttention }) - } - for (const bucket of filteredGroupedStudents) { - allSections.push({ type: 'bucket', bucket }) - } - - // Helper to check if a category is compact (1 student, no attention placeholder) - const isCategoryCompact = ( - bucket: (typeof filteredGroupedStudents)[0], - cat: (typeof bucket.categories)[0] - ) => { - const attentionCount = - attentionCountsByBucket.get(bucket.bucket)?.get(cat.category) ?? 0 - return cat.students.length === 1 && attentionCount === 0 - } - - // Helper to check if entire bucket is compact - const isBucketCompact = (bucket: (typeof filteredGroupedStudents)[0]) => - bucket.categories.every((cat) => isCategoryCompact(bucket, cat)) - - // Helper to check if a section is compact - const isSectionCompact = (section: Section) => { - if (section.type === 'attention') { - return section.students.length === 1 - } - return isBucketCompact(section.bucket) - } - - // Group consecutive compact sections - type RenderItem = - | { type: 'compact-sections'; sections: Section[] } - | { type: 'full-section'; section: Section } - - const renderItems: RenderItem[] = [] - let compactBuffer: Section[] = [] - - for (const section of allSections) { - if (isSectionCompact(section)) { - compactBuffer.push(section) - } else { - if (compactBuffer.length > 0) { - renderItems.push({ type: 'compact-sections', sections: compactBuffer }) - compactBuffer = [] - } - renderItems.push({ type: 'full-section', section }) - } - } - if (compactBuffer.length > 0) { - renderItems.push({ type: 'compact-sections', sections: compactBuffer }) - } - - return renderItems.map((item, itemIdx) => { - if (item.type === 'compact-sections') { - // Render compact sections flowing together - return ( -
- {item.sections.flatMap((section) => { - if (section.type === 'attention') { - // Compact attention section (1 student) - return ( -
- - ⚠️ - - Needs Attention - - - -
- ) - } - // Compact bucket (all single-student categories) - return section.bucket.categories.map((cat) => ( -
- - - {section.bucket.bucketName} - - - · - - {cat.categoryName} - - -
- )) - })} -
- ) - } - - // Full section - const section = item.section - - if (section.type === 'attention') { - // Full attention section (multiple students) - return ( -
-

- ⚠️ - Needs Attention - - {section.students.length} - -

- -
- ) - } - - // Full bucket - const bucket = section.bucket - - return ( -
-

- {bucket.bucketName} -

- - {/* Categories within bucket - grouped for compact display */} -
- {(() => { - // Group consecutive compact categories - type CategoryRenderItem = - | { type: 'compact-row'; categories: typeof bucket.categories } - | { type: 'full'; category: (typeof bucket.categories)[0] } - - const items: CategoryRenderItem[] = [] - let compactBuffer: typeof bucket.categories = [] - - for (const cat of bucket.categories) { - if (isCategoryCompact(bucket, cat)) { - 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 ( -
- {item.categories.map((cat) => ( -
- {/* Small inline category label */} - - {cat.categoryName} - - {/* Single student tile */} - -
- ))} -
- ) - } - - // Render full category (2+ students or has attention placeholder) - const category = item.category - const attentionCount = - attentionCountsByBucket.get(bucket.bucket)?.get(category.category) ?? - 0 - - return ( -
- {/* Category header - sticky below bucket header */} -

- {category.categoryName} -

- - {/* Student cards wrapper */} -
- {/* Student cards */} - {category.students.length > 0 && ( - - )} - - {/* Attention placeholder */} - {attentionCount > 0 && ( -
- +{attentionCount} in Needs Attention -
- )} -
-
- ) - }) - })()} -
-
- ) - }) - })()} -
+ )} {/* Hidden archived students indicator - only shown when filtering */} @@ -1041,6 +593,538 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli ) } +// ============================================================================= +// MeasuredGroupedStudents - Uses measurement-based layout for compact sections +// ============================================================================= + +interface MeasuredGroupedStudentsProps { + studentsNeedingAttention: StudentWithSkillData[] + filteredGroupedStudents: ReturnType + attentionCountsByBucket: Map> + selectedIds: Set + isDark: boolean + onSelectStudent: (student: StudentWithProgress) => void + onToggleSelection: (student: StudentWithProgress) => void + onObserveSession: (sessionId: string) => void +} + +/** + * Renders grouped students with measurement-based compact layout. + * Uses hidden measurement container to determine which items can fit together. + */ +function MeasuredGroupedStudents({ + studentsNeedingAttention, + filteredGroupedStudents, + attentionCountsByBucket, + selectedIds, + isDark, + onSelectStudent, + onToggleSelection, + onObserveSession, +}: MeasuredGroupedStudentsProps) { + // Helper to check if a category is compact (1 student, no attention placeholder) + const isCategoryCompact = ( + bucket: (typeof filteredGroupedStudents)[0], + cat: (typeof bucket.categories)[0] + ) => { + const attentionCount = attentionCountsByBucket.get(bucket.bucket)?.get(cat.category) ?? 0 + return cat.students.length === 1 && attentionCount === 0 + } + + // Helper to check if entire bucket is compact + const isBucketCompact = (bucket: (typeof filteredGroupedStudents)[0]) => + bucket.categories.every((cat) => isCategoryCompact(bucket, cat)) + + // Section type for attention and buckets + type Section = + | { type: 'attention'; students: typeof studentsNeedingAttention } + | { type: 'bucket'; bucket: (typeof filteredGroupedStudents)[0] } + + // Helper to check if a section is potentially compact + const isSectionCompact = (section: Section) => { + if (section.type === 'attention') { + return section.students.length === 1 + } + return isBucketCompact(section.bucket) + } + + // Build list of all sections (attention first, then buckets) + const allSections: Section[] = useMemo(() => { + const sections: Section[] = [] + if (studentsNeedingAttention.length > 0) { + sections.push({ type: 'attention', students: studentsNeedingAttention }) + } + for (const bucket of filteredGroupedStudents) { + sections.push({ type: 'bucket', bucket }) + } + return sections + }, [studentsNeedingAttention, filteredGroupedStudents]) + + // Chunk sections into "compact runs" and "full sections" + type Chunk = { type: 'compact-run'; sections: Section[] } | { type: 'full'; section: Section } + + const chunks: Chunk[] = useMemo(() => { + const result: Chunk[] = [] + let compactRun: Section[] = [] + + for (const section of allSections) { + if (isSectionCompact(section)) { + compactRun.push(section) + } else { + if (compactRun.length > 0) { + result.push({ type: 'compact-run', sections: compactRun }) + compactRun = [] + } + result.push({ type: 'full', section }) + } + } + if (compactRun.length > 0) { + result.push({ type: 'compact-run', sections: compactRun }) + } + return result + }, [allSections]) // eslint-disable-line react-hooks/exhaustive-deps + + // Render a compact item element (for measurement and display) + const renderCompactItem = useCallback( + (section: Section, itemKey: string) => { + if (section.type === 'attention') { + return ( +
+ + ⚠️ + + Needs Attention + + + +
+ ) + } + // This shouldn't happen for compact buckets - we expand them into categories + return null + }, + [isDark, selectedIds, onSelectStudent, onToggleSelection, onObserveSession] + ) + + // Render a compact category item element + const renderCompactCategoryItem = useCallback( + ( + bucket: (typeof filteredGroupedStudents)[0], + cat: (typeof bucket.categories)[0], + itemKey: string + ) => { + return ( +
+ + + {bucket.bucketName} + + · + {cat.categoryName} + + +
+ ) + }, + [isDark, selectedIds, onSelectStudent, onToggleSelection, onObserveSession] + ) + + // Build compact items for all compact runs (for measurement) + const compactItems: CompactItem[] = useMemo(() => { + const items: CompactItem[] = [] + for (const chunk of chunks) { + if (chunk.type === 'compact-run') { + for (const section of chunk.sections) { + if (section.type === 'attention') { + const itemKey = 'attention' + items.push({ + id: itemKey, + element: renderCompactItem(section, itemKey), + }) + } else { + // Expand bucket into individual category items + for (const cat of section.bucket.categories) { + const itemKey = `${section.bucket.bucket}-${cat.category ?? 'null'}` + items.push({ + id: itemKey, + element: renderCompactCategoryItem(section.bucket, cat, itemKey), + }) + } + } + } + } + } + return items + }, [chunks, renderCompactItem, renderCompactCategoryItem]) + + // Use measurement hook + const { containerRef, itemRefs, rows, isReady } = useMeasuredCompactLayout(compactItems, 12) + + // Create a map from item ID to which row it belongs + const itemRowMap = useMemo(() => { + const map = new Map() + rows.forEach((row, rowIdx) => { + for (const item of row) { + map.set(item.id, rowIdx) + } + }) + return map + }, [rows]) + + // Render full section + const renderFullSection = useCallback( + (section: Section, key: string) => { + if (section.type === 'attention') { + // Full attention section (multiple students) + return ( +
+

+ ⚠️ + Needs Attention + + {section.students.length} + +

+ +
+ ) + } + + // Full bucket + const bucket = section.bucket + + return ( +
+

+ {bucket.bucketName} +

+ + {/* Categories within bucket */} +
+ {bucket.categories.map((category) => { + const attentionCount = + attentionCountsByBucket.get(bucket.bucket)?.get(category.category) ?? 0 + + return ( +
+ {/* Category header - sticky below bucket header */} +

+ {category.categoryName} +

+ + {/* Student cards wrapper */} +
+ {category.students.length > 0 && ( + + )} + + {/* Attention placeholder */} + {attentionCount > 0 && ( +
+ +{attentionCount} in Needs Attention +
+ )} +
+
+ ) + })} +
+
+ ) + }, + [ + isDark, + selectedIds, + onSelectStudent, + onToggleSelection, + onObserveSession, + attentionCountsByBucket, + ] + ) + + return ( +
+ {/* Hidden measurement container */} +
+ {compactItems.map((item) => ( +
{ + if (el) itemRefs.current.set(item.id, el) + else itemRefs.current.delete(item.id) + }} + style={{ flexShrink: 0 }} + > + {item.element} +
+ ))} +
+ + {/* Visible layout */} + {isReady && + chunks.map((chunk, chunkIdx) => { + if (chunk.type === 'full') { + return renderFullSection( + chunk.section, + chunk.section.type === 'attention' ? 'attention' : chunk.section.bucket.bucket + ) + } + + // Compact run - render as measured rows + // Get all item IDs for this compact run + const runItemIds: string[] = [] + for (const section of chunk.sections) { + if (section.type === 'attention') { + runItemIds.push('attention') + } else { + for (const cat of section.bucket.categories) { + runItemIds.push(`${section.bucket.bucket}-${cat.category ?? 'null'}`) + } + } + } + + // Group items by their measured row + const rowGroups = new Map() + for (const id of runItemIds) { + const rowIdx = itemRowMap.get(id) ?? 0 + const item = compactItems.find((i) => i.id === id) + if (item) { + if (!rowGroups.has(rowIdx)) { + rowGroups.set(rowIdx, []) + } + rowGroups.get(rowIdx)!.push(item) + } + } + + // Render each row + return Array.from(rowGroups.entries()) + .sort(([a], [b]) => a - b) + .map(([rowIdx, items]) => ( +
+ {items.map((item) => ( + {item.element} + ))} +
+ )) + })} +
+ ) +} + /** * ViewEmptyState - View-specific empty states */ diff --git a/apps/web/src/components/practice/GroupedCategories.stories.tsx b/apps/web/src/components/practice/GroupedCategories.stories.tsx index 86d75781..139537fa 100644 --- a/apps/web/src/components/practice/GroupedCategories.stories.tsx +++ b/apps/web/src/components/practice/GroupedCategories.stories.tsx @@ -1,5 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { Fragment, useCallback, useMemo, useState } from 'react' +import { type CompactItem, useMeasuredCompactLayout } from '../../hooks/useMeasuredCompactLayout' import { css } from '../../../styled-system/css' import { StudentSelector, type StudentWithProgress } from './StudentSelector' @@ -22,15 +24,15 @@ function createQueryClient() { } /** - * Demonstrates the grouped category layout for the practice page. + * Demonstrates the measurement-based 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. + * The layout uses actual measured widths to determine which items can fit together + * on the same row, rather than a simple count-based heuristic. * - * 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 + * Key behaviors: + * - **Measurement-based**: Items are rendered hidden, measured, then grouped based on actual fit + * - **Responsive**: Adapts to container width changes via ResizeObserver + * - **No flash**: useLayoutEffect ensures measurement happens before paint */ const meta: Meta = { title: 'Practice/GroupedCategories', @@ -73,6 +75,7 @@ const createStudent = ( createdAt: new Date(), }) +// Students with varying name lengths to show measurement differences const students = { sonia: createStudent('1', 'Sonia', '🦋', '#FFE4E1', 3), marcus: createStudent('2', 'Marcus', '🦖', '#E0FFE0', 2), @@ -80,6 +83,10 @@ const students = { alex: createStudent('4', 'Alex', '🚀', '#FFF0E0', 2), maya: createStudent('5', 'Maya', '🌸', '#FFE0F0', 3), kai: createStudent('6', 'Kai', '🐻', '#E0F0FF', 1), + // Long names to demonstrate width-based wrapping + alexanderTheGreat: createStudent('7', 'Alexander the Great', '👑', '#FFD700', 3), + christopherColumbus: createStudent('8', 'Christopher Columbus', '🧭', '#87CEEB', 2), + elizabethBennet: createStudent('9', 'Elizabeth Bennet', '📚', '#DDA0DD', 1), } interface CategoryData { @@ -95,29 +102,27 @@ interface BucketData { categories: CategoryData[] } -// Component that replicates the exact structure from PracticeClient.tsx -function GroupedStudentsDemo({ - buckets, - needsAttentionStudents = [], - isDark = false, -}: { +// ============================================================================= +// MeasuredGroupedStudentsDemo - Uses the actual measurement hook +// ============================================================================= + +interface MeasuredGroupedStudentsDemoProps { buckets: BucketData[] needsAttentionStudents?: StudentWithProgress[] isDark?: boolean -}) { - // Build unified sections list (attention first, then buckets) - type Section = - | { type: 'attention'; students: StudentWithProgress[] } - | { type: 'bucket'; bucket: BucketData } - - const allSections: Section[] = [] - if (needsAttentionStudents.length > 0) { - allSections.push({ type: 'attention', students: needsAttentionStudents }) - } - for (const bucket of buckets) { - allSections.push({ type: 'bucket', bucket }) - } + containerWidth?: number | string // Allow controlling container width for demos +} +/** + * Demo component that uses the actual useMeasuredCompactLayout hook + * to demonstrate measurement-based row grouping. + */ +function MeasuredGroupedStudentsDemo({ + buckets, + needsAttentionStudents = [], + isDark = false, + containerWidth = '100%', +}: MeasuredGroupedStudentsDemoProps) { // Helper to check if a category is compact const isCategoryCompact = (cat: CategoryData) => cat.students.length === 1 && (cat.attentionCount ?? 0) === 0 @@ -126,6 +131,11 @@ function GroupedStudentsDemo({ const isBucketCompact = (bucket: BucketData) => bucket.categories.every((cat) => isCategoryCompact(cat)) + // Section type for attention and buckets + type Section = + | { type: 'attention'; students: StudentWithProgress[] } + | { type: 'bucket'; bucket: BucketData } + // Helper to check if a section is compact const isSectionCompact = (section: Section) => { if (section.type === 'attention') { @@ -134,42 +144,107 @@ function GroupedStudentsDemo({ return isBucketCompact(section.bucket) } - // Group consecutive compact sections - type RenderItem = - | { type: 'compact-sections'; sections: Section[] } - | { type: 'full-section'; section: Section } - - const renderItems: RenderItem[] = [] - let compactBuffer: Section[] = [] - - for (const section of allSections) { - if (isSectionCompact(section)) { - compactBuffer.push(section) - } else { - if (compactBuffer.length > 0) { - renderItems.push({ type: 'compact-sections', sections: compactBuffer }) - compactBuffer = [] - } - renderItems.push({ type: 'full-section', section }) + // Build list of all sections + const allSections: Section[] = useMemo(() => { + const sections: Section[] = [] + if (needsAttentionStudents.length > 0) { + sections.push({ type: 'attention', students: needsAttentionStudents }) } - } - if (compactBuffer.length > 0) { - renderItems.push({ type: 'compact-sections', sections: compactBuffer }) - } - // Helper to render a compact section (single attention or compact bucket) - const renderCompactSection = (section: Section, idx: number) => { - if (section.type === 'attention') { - // Single attention student - compact with label - const student = section.students[0] + for (const bucket of buckets) { + sections.push({ type: 'bucket', bucket }) + } + return sections + }, [needsAttentionStudents, buckets]) + + // Chunk sections into "compact runs" and "full sections" + type Chunk = { type: 'compact-run'; sections: Section[] } | { type: 'full'; section: Section } + + const chunks: Chunk[] = useMemo(() => { + const result: Chunk[] = [] + let compactRun: Section[] = [] + + for (const section of allSections) { + if (isSectionCompact(section)) { + compactRun.push(section) + } else { + if (compactRun.length > 0) { + result.push({ type: 'compact-run', sections: compactRun }) + compactRun = [] + } + result.push({ type: 'full', section }) + } + } + if (compactRun.length > 0) { + result.push({ type: 'compact-run', sections: compactRun }) + } + return result + }, [allSections]) // eslint-disable-line react-hooks/exhaustive-deps + + // Render compact category item + const renderCompactCategoryItem = useCallback( + (bucket: BucketData, cat: CategoryData, itemKey: string) => { return (
+ + + {bucket.bucketName} + + · + {cat.categoryName} + + {}} + onToggleSelection={() => {}} + title="" + hideAddButton + compact + /> +
+ ) + }, + [isDark] + ) + + // Render compact attention item + const renderCompactAttentionItem = useCallback( + (student: StudentWithProgress, itemKey: string) => { + return ( +
- Needs Attention + ⚠️ + + Needs Attention +
) + }, + [isDark] + ) + + // Build compact items for measurement + const compactItems: CompactItem[] = useMemo(() => { + const items: CompactItem[] = [] + for (const chunk of chunks) { + if (chunk.type === 'compact-run') { + for (const section of chunk.sections) { + if (section.type === 'attention') { + const itemKey = 'attention' + items.push({ + id: itemKey, + element: renderCompactAttentionItem(section.students[0], itemKey), + }) + } else { + for (const cat of section.bucket.categories) { + const itemKey = `${section.bucket.bucket}-${cat.category ?? 'null'}` + items.push({ + id: itemKey, + element: renderCompactCategoryItem(section.bucket, cat, itemKey), + }) + } + } + } + } } + return items + }, [chunks, renderCompactAttentionItem, renderCompactCategoryItem]) - // Compact bucket - all categories are single-student - const bucket = section.bucket - return bucket.categories.map((cat, catIdx) => ( -
- - {bucket.bucketName} - · - {cat.categoryName} - - {}} - onToggleSelection={() => {}} - title="" - hideAddButton - compact - /> -
- )) - } + // Use measurement hook + const { containerRef, itemRefs, rows, isReady } = useMeasuredCompactLayout(compactItems, 12) - // Helper to render a full section (multiple attention or non-compact bucket) - const renderFullSection = (section: Section) => { - if (section.type === 'attention') { - // Full Needs Attention section + // Create map from item ID to row index + const itemRowMap = useMemo(() => { + const map = new Map() + rows.forEach((row, rowIdx) => { + for (const item of row) { + map.set(item.id, rowIdx) + } + }) + return map + }, [rows]) + + // Render full section + const renderFullSection = useCallback( + (section: Section, key: string) => { + if (section.type === 'attention') { + return ( +
+

+ ⚠️ + Needs Attention + + {section.students.length} + +

+
+ {}} + onToggleSelection={() => {}} + title="" + hideAddButton + compact + /> +
+
+ ) + } + + const bucket = section.bucket return ( -
+

- ⚠️ Needs Attention + {bucket.bucketName}

-
- {}} - onToggleSelection={() => {}} - title="" - hideAddButton - compact - /> +
+ {bucket.categories.map((category) => ( +
+

+ {category.categoryName} +

+
+ {}} + onToggleSelection={() => {}} + title="" + hideAddButton + compact + /> + {(category.attentionCount ?? 0) > 0 && ( +
+ +{category.attentionCount} in Needs Attention +
+ )} +
+
+ ))}
) - } - - // Full bucket with categories - const bucket = section.bucket - return ( -
-

- {bucket.bucketName} -

- -
- {(() => { - // Group consecutive compact categories within the bucket - type CatRenderItem = - | { type: 'compact-row'; categories: CategoryData[] } - | { type: 'full'; category: CategoryData } - - const items: CatRenderItem[] = [] - let compactBuffer: CategoryData[] = [] - - for (const cat of bucket.categories) { - if (isCategoryCompact(cat)) { - 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') { - return ( -
- {item.categories.map((cat) => ( -
- - {cat.categoryName} - - {}} - onToggleSelection={() => {}} - title="" - hideAddButton - compact - /> -
- ))} -
- ) - } - - const category = item.category - return ( -
-

- {category.categoryName} -

-
- {}} - onToggleSelection={() => {}} - title="" - hideAddButton - compact - /> - {(category.attentionCount ?? 0) > 0 && ( -
- +{category.attentionCount} in Needs Attention -
- )} -
-
- ) - }) - })()} -
-
- ) - } + }, + [isDark] + ) return (
+ {/* Debug info */}
+ Measurement Debug: {compactItems.length} compact items → {rows.length} rows + {rows.map((row, i) => ( + + [Row {i + 1}: {row.length} items] + + ))} +
+ +
- {renderItems.map((item, idx) => { - if (item.type === 'compact-sections') { - // Render compact sections flowing together - return ( -
- {item.sections.map((section, sIdx) => renderCompactSection(section, sIdx))} -
- ) - } - return renderFullSection(item.section) - })} + {/* Hidden measurement container */} +
+ {compactItems.map((item) => ( +
{ + if (el) itemRefs.current.set(item.id, el) + else itemRefs.current.delete(item.id) + }} + style={{ flexShrink: 0 }} + > + {item.element} +
+ ))} +
+ + {/* Visible layout */} + {isReady && + chunks.map((chunk, chunkIdx) => { + if (chunk.type === 'full') { + return renderFullSection( + chunk.section, + chunk.section.type === 'attention' ? 'attention' : chunk.section.bucket.bucket + ) + } + + // Get all item IDs for this compact run + const runItemIds: string[] = [] + for (const section of chunk.sections) { + if (section.type === 'attention') { + runItemIds.push('attention') + } else { + for (const cat of section.bucket.categories) { + runItemIds.push(`${section.bucket.bucket}-${cat.category ?? 'null'}`) + } + } + } + + // Group items by measured row + const rowGroups = new Map() + for (const id of runItemIds) { + const rowIdx = itemRowMap.get(id) ?? 0 + const item = compactItems.find((i) => i.id === id) + if (item) { + if (!rowGroups.has(rowIdx)) { + rowGroups.set(rowIdx, []) + } + rowGroups.get(rowIdx)!.push(item) + } + } + + return Array.from(rowGroups.entries()) + .sort(([a], [b]) => a - b) + .map(([rowIdx, items]) => ( +
+ {items.map((item) => ( + {item.element} + ))} +
+ )) + })}
) } +// ============================================================================= +// Interactive Width Demo +// ============================================================================= + +function InteractiveWidthDemo() { + const [width, setWidth] = useState(800) + + return ( +
+
+ + setWidth(Number(e.target.value))} + className={css({ width: '200px' })} + /> +
+ +
+ ) +} + +// ============================================================================= +// STORIES +// ============================================================================= + /** - * All single-student categories flow together on the same row + * Interactive demo - drag the slider to see how items reflow based on container width */ -export const AllCompact: Story = { - render: () => ( - - ), +export const InteractiveResize: Story = { + render: () => , } /** - * All categories have multiple students - each gets its own full-width header + * Wide container (800px) - all 6 compact items may fit on fewer rows */ -export const AllFull: Story = { +export const WideContainer: Story = { render: () => ( - - ), -} - -/** - * Mix of compact and full categories - compact ones flow together, - * full categories break the flow with their own header - */ -export const Mixed: Story = { - render: () => ( - - ), -} - -/** - * Single student with attention placeholder - renders as full category - * because attention placeholder needs space - */ -export const SingleWithAttention: Story = { - render: () => ( - - ), -} - -/** - * Multiple buckets showing the full hierarchy - */ -export const MultipleBuckets: Story = { - render: () => ( - - ), -} - -/** - * Many compact categories that wrap to multiple rows - */ -export const ManyCompactWrapping: Story = { - render: () => ( - ( - + ), +} + +/** + * Very narrow container (300px) - each item on its own row + */ +export const VeryNarrowContainer: Story = { + render: () => ( + + ), +} + +/** + * Long student names cause items to be wider, affecting row grouping + */ +export const LongStudentNames: Story = { + render: () => ( + ), - parameters: { - backgrounds: { default: 'dark' }, - }, } /** - * Realistic scenario with various category sizes across buckets + * Mix of long and short names */ -export const RealisticScenario: Story = { +export const MixedNameLengths: Story = { render: () => ( - + ), +} + +/** + * Multiple students in a category - renders as full section, not compact + */ +export const MultiStudentCategory: Story = { + render: () => ( + + ), +} + +/** + * Mix of compact and full sections - full sections break the flow + */ +export const MixedCompactAndFull: Story = { + render: () => ( + ), } -// ============================================================================= -// NEEDS ATTENTION STORIES -// ============================================================================= - /** - * Single student needing attention - renders compact, flows with other compact sections + * Needs Attention section (single student) - compact, flows with other compact items */ -export const NeedsAttentionSingle: Story = { +export const NeedsAttentionSingleCompact: Story = { render: () => ( - ( - ( - - ), -} - -/** - * Multiple attention students (full section) followed by compact buckets - */ -export const NeedsAttentionFullThenCompact: Story = { - render: () => ( - ( - ( - ( + + ), +} + +/** + * Edge case: empty buckets + */ +export const EmptyState: Story = { + render: () => , +} diff --git a/apps/web/src/hooks/useMeasuredCompactLayout.ts b/apps/web/src/hooks/useMeasuredCompactLayout.ts new file mode 100644 index 00000000..1dc9df7f --- /dev/null +++ b/apps/web/src/hooks/useMeasuredCompactLayout.ts @@ -0,0 +1,157 @@ +import { + type MutableRefObject, + type ReactNode, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react' + +export interface CompactItem { + id: string + element: ReactNode +} + +export interface UseMeasuredCompactLayoutResult { + /** Ref to attach to the container element (for measuring available width) */ + containerRef: MutableRefObject + /** Ref map for individual items - used internally by MeasurementContainer */ + itemRefs: MutableRefObject> + /** Items grouped into rows based on measured fit */ + rows: CompactItem[][] + /** Whether measurements are complete and rows are ready */ + isReady: boolean +} + +/** + * Groups items into rows based on actual measured widths. + * + * Uses useLayoutEffect to measure before paint, so there's no flash of wrong layout. + * + * @param items - Items to measure and group + * @param gap - Gap between items in pixels + * @returns Object with containerRef, itemRefs, grouped rows, and ready state + * + * @example + * ```tsx + * const { containerRef, itemRefs, rows, isReady } = useMeasuredCompactLayout(items, 12) + * + * return ( + *
+ * + * {isReady && rows.map((row, i) => ( + *
+ * {row.map(item => {item.element})} + *
+ * ))} + *
+ * ) + * ``` + */ +export function useMeasuredCompactLayout( + items: CompactItem[], + gap: number +): UseMeasuredCompactLayoutResult { + const containerRef = useRef(null) + const itemRefs = useRef>(new Map()) + + const [rows, setRows] = useState([]) + const [isReady, setIsReady] = useState(false) + const [measurementTrigger, setMeasurementTrigger] = useState(0) + + // Create a stable items key for dependency tracking + const itemsKey = items.map((item) => item.id).join(',') + + // Measure and group - runs synchronously before paint + useLayoutEffect(() => { + const container = containerRef.current + if (!container) { + return + } + + if (items.length === 0) { + setRows([]) + setIsReady(true) + return + } + + const containerWidth = container.getBoundingClientRect().width + + // Measure each item + const measurements: { item: CompactItem; width: number }[] = [] + for (const item of items) { + const el = itemRefs.current.get(item.id) + if (el) { + measurements.push({ + item, + width: el.getBoundingClientRect().width, + }) + } + } + + // Group items that fit together + const grouped = groupByFit(measurements, containerWidth, gap) + setRows(grouped) + setIsReady(true) + }, [itemsKey, gap, measurementTrigger]) // eslint-disable-line react-hooks/exhaustive-deps + + // Re-measure on container resize + useEffect(() => { + const container = containerRef.current + if (!container) return + + const observer = new ResizeObserver(() => { + // Trigger re-measurement + setMeasurementTrigger((t) => t + 1) + }) + + observer.observe(container) + return () => observer.disconnect() + }, []) + + return { + containerRef, + itemRefs, + rows, + isReady, + } +} + +/** + * Groups items into rows based on whether they fit within the container width. + */ +function groupByFit( + measurements: { item: CompactItem; width: number }[], + containerWidth: number, + gap: number +): CompactItem[][] { + if (containerWidth <= 0) { + // If container has no width, put each item in its own row + return measurements.map(({ item }) => [item]) + } + + const rows: CompactItem[][] = [] + let currentRow: CompactItem[] = [] + let currentWidth = 0 + + for (const { item, width } of measurements) { + const widthNeeded = currentRow.length > 0 ? width + gap : width + + if (currentWidth + widthNeeded <= containerWidth) { + currentRow.push(item) + currentWidth += widthNeeded + } else { + if (currentRow.length > 0) { + rows.push(currentRow) + } + currentRow = [item] + currentWidth = width + } + } + + if (currentRow.length > 0) { + rows.push(currentRow) + } + + return rows +}