-
Notifications
You must be signed in to change notification settings - Fork 230
feat: Implement Dynamic Flexible Grid Layout Engine (#3345) #3361
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,98 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| export type TrackUnit = 'auto' | 'px' | 'percent' | 'fr'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| export interface TrackSpec { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| value: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| unit: TrackUnit; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| export interface GridCellRect { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| x: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| y: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| width: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| height: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| export class GridLayoutManager { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||
| * Parse a track specification string like "100px 1fr 50% auto". | ||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| static parseTrackSpec(specStr: string): TrackSpec[] { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const tokens = specStr.trim().split(/\s+/).filter(Boolean); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return tokens.map((token) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (token === 'auto') return { value: 0, unit: 'auto' }; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (token.endsWith('px')) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return { value: parseFloat(token.slice(0, -2)) || 0, unit: 'px' }; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (token.endsWith('%')) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return { value: parseFloat(token.slice(0, -1)) || 0, unit: 'percent' }; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (token.endsWith('fr')) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return { value: parseFloat(token.slice(0, -2)) || 1, unit: 'fr' }; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const num = parseFloat(token); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return isNaN(num) ? { value: 0, unit: 'auto' } : { value: num, unit: 'px' }; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||
| * Calculate track sizes given total container size and track specifications. | ||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| static computeTrackSizes(totalSize: number, tracks: TrackSpec[]): number[] { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (tracks.length === 0) return []; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const sizes = new Array<number>(tracks.length).fill(0); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| let remainingSize = totalSize; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| let totalFr = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| // First pass: allocate fixed px and percentage units | ||||||||||||||||||||||||||||||||||||||||||||||||||
| tracks.forEach((track, i) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (track.unit === 'px') { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| sizes[i] = Math.max(0, Math.min(remainingSize, track.value)); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| remainingSize -= sizes[i]; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } else if (track.unit === 'percent') { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const allocated = Math.floor((totalSize * track.value) / 100); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| sizes[i] = Math.max(0, Math.min(remainingSize, allocated)); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| remainingSize -= sizes[i]; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } else if (track.unit === 'fr') { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| totalFr += track.value; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| // Second pass: allocate fr units from remaining size | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (totalFr > 0 && remainingSize > 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| tracks.forEach((track, i) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (track.unit === 'fr') { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| sizes[i] = Math.floor((remainingSize * track.value) / totalFr); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+61
to
+68
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π― Functional Correctness | π‘ Minor | β‘ Quick win Distribute the fractional rounding remainder. Independent Allocate the remainder to one fractional track or use a largest-remainder distribution. Add a test that verifies the fractional sizes sum to the remaining space. Proposed fix+ const frIndexes = tracks.flatMap((track, index) =>
+ track.unit === 'fr' && track.value > 0 ? [index] : [],
+ );
+ let allocatedFrSize = 0;
tracks.forEach((track, i) => {
if (track.unit === 'fr') {
- sizes[i] = Math.floor((remainingSize * track.value) / totalFr);
+ sizes[i] =
+ i === frIndexes[frIndexes.length - 1]
+ ? remainingSize - allocatedFrSize
+ : Math.floor((remainingSize * track.value) / totalFr);
+ allocatedFrSize += sizes[i];
}
});π Committable suggestion
Suggested change
π€ Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| return sizes; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||
| * Calculate cell rectangle for a given row and column index in the grid. | ||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| static getCellRect( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| colIndex: number, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| rowIndex: number, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| colSizes: number[], | ||||||||||||||||||||||||||||||||||||||||||||||||||
| rowSizes: number[], | ||||||||||||||||||||||||||||||||||||||||||||||||||
| startPoint: { x: number; y: number } = { x: 0, y: 0 } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ): GridCellRect { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| let x = startPoint.x; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| for (let i = 0; i < colIndex && i < colSizes.length; i++) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| x += colSizes[i]; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| let y = startPoint.y; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| for (let j = 0; j < rowIndex && j < rowSizes.length; j++) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| y += rowSizes[j]; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const width = colSizes[colIndex] ?? 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const height = rowSizes[rowIndex] ?? 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| return { x, y, width, height }; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { describe, it, expect } from 'bun:test'; | ||
| import { GridLayoutManager } from '../src/layout/gridLayout'; | ||
|
|
||
| describe('GridLayoutManager Unit Tests', () => { | ||
| it('should parse track specifications correctly', () => { | ||
| const tracks = GridLayoutManager.parseTrackSpec('100px 1fr 50% auto'); | ||
| expect(tracks).toEqual([ | ||
| { value: 100, unit: 'px' }, | ||
| { value: 1, unit: 'fr' }, | ||
| { value: 50, unit: 'percent' }, | ||
| { value: 0, unit: 'auto' }, | ||
| ]); | ||
| }); | ||
|
|
||
| it('should compute track sizes for mixed px, percent, and fr tracks', () => { | ||
| const tracks = GridLayoutManager.parseTrackSpec('20px 50% 1fr'); | ||
| // Total 100px. Fixed 20px, 50% = 50px. Remaining = 30px -> 1fr gets 30px. | ||
| const sizes = GridLayoutManager.computeTrackSizes(100, tracks); | ||
| expect(sizes).toEqual([20, 50, 30]); | ||
| }); | ||
|
|
||
| it('should compute equal track sizes for multiple fr tracks', () => { | ||
| const tracks = GridLayoutManager.parseTrackSpec('1fr 2fr'); | ||
| const sizes = GridLayoutManager.computeTrackSizes(90, tracks); | ||
| expect(sizes).toEqual([30, 60]); | ||
| }); | ||
|
Comment on lines
+22
to
+26
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π― Functional Correctness | π‘ Minor | β‘ Quick win Cover non-divisible Lines 22-26 use a divisible total, so the test does not detect rounding gaps. For a total size of Add a regression case and distribute the remainder, or define an explicit remainder policy, before relying on this test for arbitrary terminal sizes. Suggested regression test+ it('should fill the container when fr tracks require rounding', () => {
+ const tracks = GridLayoutManager.parseTrackSpec('1fr 1fr 1fr');
+ const sizes = GridLayoutManager.computeTrackSizes(100, tracks);
+
+ expect(sizes.reduce((total, size) => total + size, 0)).toBe(100);
+ });π€ Prompt for AI Agents |
||
|
|
||
| it('should calculate correct cell rectangle coordinates', () => { | ||
| const colSizes = [20, 30, 50]; | ||
| const rowSizes = [10, 15]; | ||
|
|
||
| const rect = GridLayoutManager.getCellRect(1, 1, colSizes, rowSizes, { x: 5, y: 5 }); | ||
| expect(rect).toEqual({ | ||
| x: 25, // 5 + 20 | ||
| y: 15, // 5 + 10 | ||
| width: 30, | ||
| height: 15, | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π― Functional Correctness | π‘ Minor | β‘ Quick win
Preserve zero
frvalues and reject invalid factors.parseTrackSpec('0fr')currently returns{ value: 1, unit: 'fr' }because0 || 1is truthy fallback logic. This changes an explicit zero-flex track into a flexible track.parseFloatalso accepts numeric prefixes for malformed tokens. SinceTrackSpecis public, callers can bypass the parser and pass negative or non-finitefrvalues. The allocation formula can then produce negative orNaNtrack sizes.Use complete-token numeric parsing. Treat only the bare
frtoken as the default value1. ValidateTrackSpec.valuebefore adding it tototalFr.Proposed fix
Also applies to: 56-65
π€ Prompt for AI Agents