Skip to content

Commit 3347d68

Browse files
committed
feat(cli): add useGridLayout hook
1 parent 9aa4284 commit 3347d68

File tree

2 files changed

+397
-0
lines changed

2 files changed

+397
-0
lines changed
Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
import { describe, test, expect } from 'bun:test'
2+
3+
import {
4+
computeGridLayout,
5+
WIDTH_MD_THRESHOLD,
6+
WIDTH_LG_THRESHOLD,
7+
WIDTH_XL_THRESHOLD,
8+
} from '../use-grid-layout'
9+
import { MIN_COLUMN_WIDTH } from '../../utils/layout-helpers'
10+
11+
describe('computeGridLayout', () => {
12+
describe('threshold constants', () => {
13+
test('thresholds are in ascending order', () => {
14+
expect(WIDTH_MD_THRESHOLD).toBeLessThan(WIDTH_LG_THRESHOLD)
15+
expect(WIDTH_LG_THRESHOLD).toBeLessThan(WIDTH_XL_THRESHOLD)
16+
})
17+
18+
test('WIDTH_MD_THRESHOLD is 100', () => {
19+
expect(WIDTH_MD_THRESHOLD).toBe(100)
20+
})
21+
22+
test('WIDTH_LG_THRESHOLD is 150', () => {
23+
expect(WIDTH_LG_THRESHOLD).toBe(150)
24+
})
25+
26+
test('WIDTH_XL_THRESHOLD is 200', () => {
27+
expect(WIDTH_XL_THRESHOLD).toBe(200)
28+
})
29+
})
30+
31+
describe('maxColumns based on availableWidth', () => {
32+
test('narrow width (< 100) gets 1 column max', () => {
33+
const items = ['a', 'b', 'c', 'd']
34+
const result = computeGridLayout(items, 80)
35+
expect(result.columns).toBe(1)
36+
})
37+
38+
test('medium width (100-149) gets 2 columns max', () => {
39+
const items = ['a', 'b', 'c', 'd']
40+
const result = computeGridLayout(items, 120)
41+
expect(result.columns).toBe(2)
42+
})
43+
44+
test('large width (150-199) gets 3 columns max', () => {
45+
const items = ['a', 'b', 'c', 'd', 'e', 'f']
46+
const result = computeGridLayout(items, 180)
47+
expect(result.columns).toBe(3)
48+
})
49+
50+
test('extra large width (>= 200) gets 4 columns max', () => {
51+
const items = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
52+
const result = computeGridLayout(items, 250)
53+
expect(result.columns).toBe(4)
54+
})
55+
})
56+
57+
describe('threshold boundaries', () => {
58+
test('width 99 gives 1 column max', () => {
59+
const items = ['a', 'b', 'c']
60+
const result = computeGridLayout(items, 99)
61+
expect(result.columns).toBe(1)
62+
})
63+
64+
test('width 100 gives 2 columns max', () => {
65+
const items = ['a', 'b', 'c']
66+
const result = computeGridLayout(items, 100)
67+
expect(result.columns).toBe(2)
68+
})
69+
70+
test('width 149 gives 2 columns max', () => {
71+
const items = ['a', 'b', 'c']
72+
const result = computeGridLayout(items, 149)
73+
expect(result.columns).toBe(2)
74+
})
75+
76+
test('width 150 gives 3 columns max', () => {
77+
const items = ['a', 'b', 'c']
78+
const result = computeGridLayout(items, 150)
79+
expect(result.columns).toBe(3)
80+
})
81+
82+
test('width 199 gives 3 columns max (but 4 items prefer 2x2)', () => {
83+
// 4 items with maxColumns=3 prefers 2 columns (2x2 grid) via computeSmartColumns
84+
const items = ['a', 'b', 'c', 'd']
85+
const result = computeGridLayout(items, 199)
86+
expect(result.columns).toBe(2)
87+
88+
// 3 items actually uses 3 columns
89+
const threeItems = ['a', 'b', 'c']
90+
const result3 = computeGridLayout(threeItems, 199)
91+
expect(result3.columns).toBe(3)
92+
})
93+
94+
test('width 200 gives 4 columns max', () => {
95+
const items = ['a', 'b', 'c', 'd']
96+
const result = computeGridLayout(items, 200)
97+
expect(result.columns).toBe(4)
98+
})
99+
})
100+
101+
describe('column count based on item count', () => {
102+
test('0 items gives 1 column', () => {
103+
const result = computeGridLayout([], 200)
104+
expect(result.columns).toBe(1)
105+
})
106+
107+
test('1 item gives 1 column', () => {
108+
const result = computeGridLayout(['a'], 200)
109+
expect(result.columns).toBe(1)
110+
})
111+
112+
test('2 items on wide screen gives 2 columns', () => {
113+
const result = computeGridLayout(['a', 'b'], 200)
114+
expect(result.columns).toBe(2)
115+
})
116+
117+
test('3 items on wide screen gives 3 columns', () => {
118+
const result = computeGridLayout(['a', 'b', 'c'], 200)
119+
expect(result.columns).toBe(3)
120+
})
121+
122+
test('4 items on 3-column max gives 2 columns (2x2 grid)', () => {
123+
const result = computeGridLayout(['a', 'b', 'c', 'd'], 180)
124+
expect(result.columns).toBe(2)
125+
})
126+
127+
test('6 items on 3-column max gives 3 columns', () => {
128+
const result = computeGridLayout(['a', 'b', 'c', 'd', 'e', 'f'], 180)
129+
expect(result.columns).toBe(3)
130+
})
131+
})
132+
133+
describe('columnWidth calculation', () => {
134+
test('single column uses full availableWidth', () => {
135+
const result = computeGridLayout(['a'], 120)
136+
expect(result.columnWidth).toBe(120)
137+
})
138+
139+
test('2 columns splits width with 1 char gap', () => {
140+
const result = computeGridLayout(['a', 'b'], 121)
141+
// 121 - 1 gap = 120, divided by 2 = 60
142+
expect(result.columnWidth).toBe(60)
143+
})
144+
145+
test('3 columns splits width with 2 char gaps', () => {
146+
const result = computeGridLayout(['a', 'b', 'c'], 182)
147+
// 182 - 2 gaps = 180, divided by 3 = 60
148+
expect(result.columnWidth).toBe(60)
149+
})
150+
151+
test('4 columns splits width with 3 char gaps', () => {
152+
const result = computeGridLayout(['a', 'b', 'c', 'd'], 243)
153+
// 243 - 3 gaps = 240, divided by 4 = 60
154+
expect(result.columnWidth).toBe(60)
155+
})
156+
157+
test('columnWidth respects MIN_COLUMN_WIDTH', () => {
158+
const result = computeGridLayout(['a', 'b', 'c', 'd'], 200)
159+
expect(result.columnWidth).toBeGreaterThanOrEqual(MIN_COLUMN_WIDTH)
160+
})
161+
162+
test('very narrow width with multiple items clamps to MIN_COLUMN_WIDTH', () => {
163+
// Force 2 columns with narrow width
164+
const result = computeGridLayout(['a', 'b'], 105)
165+
// 105 - 1 gap = 104, divided by 2 = 52
166+
expect(result.columnWidth).toBe(52)
167+
})
168+
})
169+
170+
describe('columnGroups distribution (round-robin)', () => {
171+
test('empty items gives single empty column', () => {
172+
const result = computeGridLayout([], 200)
173+
expect(result.columnGroups).toEqual([[]])
174+
})
175+
176+
test('1 item in 1 column', () => {
177+
const result = computeGridLayout(['a'], 200)
178+
expect(result.columnGroups).toEqual([['a']])
179+
})
180+
181+
test('2 items distributed across 2 columns', () => {
182+
const result = computeGridLayout(['a', 'b'], 200)
183+
expect(result.columnGroups).toEqual([['a'], ['b']])
184+
})
185+
186+
test('3 items distributed across 3 columns', () => {
187+
const result = computeGridLayout(['a', 'b', 'c'], 200)
188+
expect(result.columnGroups).toEqual([['a'], ['b'], ['c']])
189+
})
190+
191+
test('4 items in 2 columns (round-robin)', () => {
192+
const result = computeGridLayout(['a', 'b', 'c', 'd'], 120)
193+
expect(result.columnGroups).toEqual([
194+
['a', 'c'],
195+
['b', 'd'],
196+
])
197+
})
198+
199+
test('5 items in 2 columns (uneven distribution)', () => {
200+
const result = computeGridLayout(['a', 'b', 'c', 'd', 'e'], 120)
201+
expect(result.columnGroups).toEqual([
202+
['a', 'c', 'e'],
203+
['b', 'd'],
204+
])
205+
})
206+
207+
test('6 items in 3 columns', () => {
208+
const result = computeGridLayout(['a', 'b', 'c', 'd', 'e', 'f'], 180)
209+
expect(result.columnGroups).toEqual([
210+
['a', 'd'],
211+
['b', 'e'],
212+
['c', 'f'],
213+
])
214+
})
215+
216+
test('7 items in 3 columns (uneven)', () => {
217+
const result = computeGridLayout(
218+
['a', 'b', 'c', 'd', 'e', 'f', 'g'],
219+
180,
220+
)
221+
expect(result.columnGroups).toEqual([
222+
['a', 'd', 'g'],
223+
['b', 'e'],
224+
['c', 'f'],
225+
])
226+
})
227+
})
228+
229+
describe('return value structure', () => {
230+
test('returns all expected properties', () => {
231+
const result = computeGridLayout(['a', 'b'], 120)
232+
expect(result).toHaveProperty('columns')
233+
expect(result).toHaveProperty('columnWidth')
234+
expect(result).toHaveProperty('columnGroups')
235+
})
236+
237+
test('columns is a positive integer', () => {
238+
const result = computeGridLayout(['a', 'b', 'c'], 150)
239+
expect(Number.isInteger(result.columns)).toBe(true)
240+
expect(result.columns).toBeGreaterThan(0)
241+
})
242+
243+
test('columnWidth is a positive number', () => {
244+
const result = computeGridLayout(['a', 'b'], 120)
245+
expect(result.columnWidth).toBeGreaterThan(0)
246+
})
247+
248+
test('columnGroups length matches columns', () => {
249+
const result = computeGridLayout(['a', 'b', 'c'], 150)
250+
expect(result.columnGroups.length).toBe(result.columns)
251+
})
252+
253+
test('total items in columnGroups equals input items', () => {
254+
const items = ['a', 'b', 'c', 'd', 'e']
255+
const result = computeGridLayout(items, 120)
256+
const totalItems = result.columnGroups.flat().length
257+
expect(totalItems).toBe(items.length)
258+
})
259+
})
260+
261+
describe('generic type support', () => {
262+
test('works with number items', () => {
263+
const result = computeGridLayout([1, 2, 3, 4], 120)
264+
expect(result.columnGroups).toEqual([
265+
[1, 3],
266+
[2, 4],
267+
])
268+
})
269+
270+
test('works with object items', () => {
271+
const items = [{ id: 1 }, { id: 2 }, { id: 3 }]
272+
const result = computeGridLayout(items, 150)
273+
expect(result.columnGroups[0][0]).toEqual({ id: 1 })
274+
expect(result.columnGroups[1][0]).toEqual({ id: 2 })
275+
expect(result.columnGroups[2][0]).toEqual({ id: 3 })
276+
})
277+
278+
test('preserves item references', () => {
279+
const obj1 = { id: 1 }
280+
const obj2 = { id: 2 }
281+
const result = computeGridLayout([obj1, obj2], 120)
282+
expect(result.columnGroups[0][0]).toBe(obj1)
283+
expect(result.columnGroups[1][0]).toBe(obj2)
284+
})
285+
})
286+
287+
describe('edge cases', () => {
288+
test('very small availableWidth (< MIN_COLUMN_WIDTH)', () => {
289+
const result = computeGridLayout(['a', 'b'], 5)
290+
expect(result.columns).toBe(1)
291+
expect(result.columnWidth).toBe(5)
292+
})
293+
294+
test('zero availableWidth', () => {
295+
const result = computeGridLayout(['a'], 0)
296+
expect(result.columns).toBe(1)
297+
expect(result.columnWidth).toBe(0)
298+
})
299+
300+
test('negative availableWidth', () => {
301+
const result = computeGridLayout(['a'], -10)
302+
expect(result.columns).toBe(1)
303+
expect(result.columnWidth).toBe(-10)
304+
})
305+
306+
test('large number of items', () => {
307+
const items = Array.from({ length: 100 }, (_, i) => i)
308+
const result = computeGridLayout(items, 250)
309+
expect(result.columns).toBe(4)
310+
expect(result.columnGroups.length).toBe(4)
311+
expect(result.columnGroups.flat().length).toBe(100)
312+
})
313+
314+
test('fractional availableWidth is floored for columnWidth', () => {
315+
const result = computeGridLayout(['a', 'b'], 121)
316+
// (121 - 1) / 2 = 60
317+
expect(result.columnWidth).toBe(60)
318+
})
319+
})
320+
321+
describe('consistency', () => {
322+
test('same input always produces same output', () => {
323+
const items = ['a', 'b', 'c', 'd']
324+
const width = 150
325+
326+
const result1 = computeGridLayout(items, width)
327+
const result2 = computeGridLayout(items, width)
328+
const result3 = computeGridLayout(items, width)
329+
330+
expect(result1.columns).toBe(result2.columns)
331+
expect(result2.columns).toBe(result3.columns)
332+
expect(result1.columnWidth).toBe(result2.columnWidth)
333+
expect(result1.columnGroups).toEqual(result2.columnGroups)
334+
})
335+
336+
test('deterministic across all threshold boundaries', () => {
337+
const items = ['a', 'b', 'c', 'd']
338+
const boundaries = [99, 100, 149, 150, 199, 200, 250]
339+
340+
for (const width of boundaries) {
341+
const result1 = computeGridLayout(items, width)
342+
const result2 = computeGridLayout(items, width)
343+
expect(result1.columns).toBe(result2.columns)
344+
}
345+
})
346+
})
347+
})

cli/src/hooks/use-grid-layout.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useMemo } from 'react'
2+
3+
import { computeSmartColumns, MIN_COLUMN_WIDTH } from '../utils/layout-helpers'
4+
5+
export const WIDTH_MD_THRESHOLD = 100
6+
export const WIDTH_LG_THRESHOLD = 150
7+
export const WIDTH_XL_THRESHOLD = 200
8+
9+
const WIDTH_THRESHOLDS = [WIDTH_MD_THRESHOLD, WIDTH_LG_THRESHOLD, WIDTH_XL_THRESHOLD] as const
10+
11+
export interface GridLayoutResult<T> {
12+
columns: number
13+
columnWidth: number
14+
columnGroups: T[][]
15+
}
16+
17+
export function computeGridLayout<T>(
18+
items: T[],
19+
availableWidth: number,
20+
): GridLayoutResult<T> {
21+
const maxColumns = WIDTH_THRESHOLDS.filter(t => availableWidth >= t).length + 1
22+
23+
const columns = computeSmartColumns(items.length, maxColumns)
24+
25+
let columnWidth: number
26+
if (columns === 1) {
27+
columnWidth = availableWidth
28+
} else {
29+
const totalGap = columns - 1
30+
const rawWidth = Math.floor((availableWidth - totalGap) / columns)
31+
columnWidth = Math.max(MIN_COLUMN_WIDTH, rawWidth)
32+
}
33+
34+
const columnGroups: T[][] = Array.from({ length: columns }, () => [])
35+
items.forEach((item, idx) => {
36+
columnGroups[idx % columns].push(item)
37+
})
38+
39+
return { columns, columnWidth, columnGroups }
40+
}
41+
42+
export function useGridLayout<T>(
43+
items: T[],
44+
availableWidth: number,
45+
): GridLayoutResult<T> {
46+
return useMemo(
47+
() => computeGridLayout(items, availableWidth),
48+
[items, availableWidth],
49+
)
50+
}

0 commit comments

Comments
 (0)