-
Notifications
You must be signed in to change notification settings - Fork 0
Open
Description
Goal
Add a layout computation utility to Grid that automatically distributes colSpan and rowSpan across items based on priority signals, producing clean grid fills with visual hierarchy. Grid.Item already has an unused priority prop (primary/secondary/tertiary) — make it functional.
Exact Implementation Requirements
Required Interface/Class Structure
// Grid layout utility - computes span assignments for clean fills
interface LayoutItem {
colSpan: 1 | 2 | 3 | 4;
rowSpan: 1 | 2 | 3;
}
interface ComputeLayoutOptions {
/** Number of grid columns (1-6) */
columns: number;
/** Total number of items to place */
itemCount: number;
/** Priority scores per item (higher = more prominent). Index-aligned. */
priorities: number[];
/** Minimum priority score required for wide treatment (colSpan > 1). Default: 3 */
wideThreshold?: number;
/** Minimum priority score required for tall treatment (rowSpan > 1). Default: top scorer */
tallThreshold?: number;
}
/**
* Returns span assignments for each item, ensuring:
* - Total cells consumed = rows * columns (no empty cells)
* - Top scorer gets rowSpan=2 (tall) when itemCount >= 6
* - Remaining extras distributed as colSpan=2 (wide) to next highest scorers
* - When base grid has no extras (itemCount % columns === 0), adds a row
*/
function computeGridLayout(options: ComputeLayoutOptions): LayoutItem[];Grid.Item Priority Prop
// Make the existing priority prop functional
interface GridItemProps {
priority?: 'primary' | 'secondary' | 'tertiary';
// When used with a smart layout Grid, priority maps to span assignments:
// primary -> eligible for rowSpan=2 (tall hero)
// secondary -> eligible for colSpan=2 (wide feature)
// tertiary -> standard 1x1 (default)
}Behavior Requirements
- When
itemCount >= 6and base extras (ceil(itemCount/columns) * columns - itemCount) are available, the top-priority item getsrowSpan=2 - When base extras are 0 (perfect grid fill like 9 items in 3 columns), automatically add one row to create hierarchy: 1 tall + (columns-1) wide items
- Wide items (
colSpan=2) are assigned to items scoring abovewideThresholduntil extras are exhausted - Every extra cell must be consumed — no empty cells in the final grid
- Grid should apply
grid-auto-flow: dense(viagrid-flow-denseclass) when smart layout is active, so CSS Grid backfills gaps
Grid math:
- Each wide item costs 1 extra cell (occupies 2 cells, represents 1 item)
- Each tall item costs 1 extra cell (occupies 2 cells across rows, represents 1 item)
extras = (rows * columns) - itemCountmust equalnumTall + numWide
Example distributions (3-column grid):
| Items | Base Rows | Extras | Layout |
|---|---|---|---|
| 6 | 2 | 0 -> add row -> 3 | 1 tall + 2 wide + 3 standard |
| 7 | 3 | 2 | 1 tall + 1 wide + 5 standard |
| 8 | 3 | 1 | 1 tall + 7 standard |
| 9 | 3 | 0 -> add row -> 3 | 1 tall + 2 wide + 6 standard |
| 10 | 4 | 2 | 1 tall + 1 wide + 8 standard |
| 12 | 4 | 0 -> add row -> 3 | 1 tall + 2 wide + 9 standard |
Error Handling
- If
itemCountis 0, return empty array - If
columnsis not 1-6, throwRangeError('columns must be between 1 and 6') - If
priorities.length !== itemCount, throwRangeError('priorities length must match itemCount') - If
itemCount < 6, skip tall/wide distribution entirely (not enough items for hierarchy)
Acceptance Criteria
Functional Tests Required
import { computeGridLayout } from '@/lib/grid-layout';
// Clean fill with extras
test('8 items in 3 columns: 1 tall, 7 standard', () => {
const result = computeGridLayout({
columns: 3,
itemCount: 8,
priorities: [5, 4, 4, 3, 3, 2, 2, 1],
});
const totalCells = result.reduce((sum, item) => sum + item.colSpan * item.rowSpan, 0);
expect(totalCells).toBe(9); // 3 rows * 3 cols
expect(result[0]).toEqual({ colSpan: 1, rowSpan: 2 }); // top scorer is tall
expect(result.filter(r => r.colSpan > 1)).toHaveLength(0); // no wide (only 1 extra, used by tall)
});
// Perfect fill needs extra row
test('9 items in 3 columns: adds row, 1 tall + 2 wide', () => {
const result = computeGridLayout({
columns: 3,
itemCount: 9,
priorities: [6, 5, 4, 3, 3, 3, 2, 2, 1],
});
const totalCells = result.reduce((sum, item) => sum + item.colSpan * item.rowSpan, 0);
expect(totalCells).toBe(12); // 4 rows * 3 cols (added 1 row)
expect(result[0]).toEqual({ colSpan: 1, rowSpan: 2 }); // tall
expect(result.filter(r => r.colSpan === 2)).toHaveLength(2); // 2 wide
});
// Small collections skip hierarchy
test('4 items: all standard, no tall/wide', () => {
const result = computeGridLayout({
columns: 3,
itemCount: 4,
priorities: [5, 4, 3, 2],
});
expect(result.every(r => r.colSpan === 1 && r.rowSpan === 1)).toBe(true);
});
// Empty
test('0 items returns empty', () => {
expect(computeGridLayout({ columns: 3, itemCount: 0, priorities: [] })).toEqual([]);
});
// Validation
test('mismatched priorities throws', () => {
expect(() => computeGridLayout({ columns: 3, itemCount: 5, priorities: [1, 2] }))
.toThrow('priorities length must match itemCount');
});Performance Requirements
- O(n log n) for sorting priorities, O(n) for assignment pass
- No allocations beyond the output array and sorted index array
TypeScript Requirements
- TypeScript strict mode, no
anytypes - Return type fully specified as
LayoutItem[] - All parameters validated
Build Requirements
- Biome 2.3.2 linting passes
- No
forEach, novar, noconsole.log
What NOT to Include
- Application-specific scoring logic (courses, products, etc.) — consumers compute their own priority scores
- Visual styling or gradient generation — that's consumer-side
- Responsive breakpoint logic — consumers add
max-md:col-span-1 max-md:row-span-1themselves - Grid preset integration (separate follow-up if needed)
File Locations
- Implementation:
src/lib/grid-layout.ts - Unit Tests:
src/lib/grid-layout.test.ts - Types: inline in implementation file
- Export from:
src/lib/index.ts
Integration Requirements
Dependencies
- None — pure computation, no DOM or React dependency
Usage Examples
import { computeGridLayout } from '@rafters/grid-layout';
import { Grid } from '@rafters/grid';
// Consumer scores their items however they want
const priorities = courses.map(c => scoreCourse(c.frontmatter));
const layout = computeGridLayout({
columns: 3,
itemCount: courses.length,
priorities,
});
// Apply to Grid
<Grid columns={3} gap="6" className="grid-flow-dense max-md:grid-cols-1">
{courses.map((course, i) => (
<Grid.Item
key={course.slug}
colSpan={layout[i].colSpan}
rowSpan={layout[i].rowSpan}
className="max-md:col-span-1 max-md:row-span-1"
>
<CourseCard
slug={course.slug}
frontmatter={course.frontmatter}
hero={layout[i].colSpan > 1 || layout[i].rowSpan > 1}
/>
</Grid.Item>
))}
</Grid>Success Criteria
- All functional tests pass
- Every test case produces zero empty cells (total cells = rows * columns)
- TypeScript compiles without errors
- Biome lint passes
- Exported from package index
This issue is complete when: computeGridLayout produces mathematically correct clean-fill layouts for any item count 0-100 across all supported column counts (1-6), with tall/wide distribution for counts >= 6.
Context & References
- Related issues: Container article headings use text-accent which fails contrast #760 (article heading contrast), Generated components fail oxfmt formatting check #756 (oxlint errors)
- Grid.Item already has unused
priorityprop — this gives it purpose - Discovered during first production use of Rafters in a courses platform
- Current workaround: application-level
computeLayoutfunction in route file (works but should be a Rafters primitive)
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels