- ⚠️ 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