Skip to content

Commit 67a2215

Browse files
committed
Implement responsive
1 parent f1a44e4 commit 67a2215

File tree

4 files changed

+300
-42
lines changed

4 files changed

+300
-42
lines changed
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
2+
import { registerCodegen } from '../code-impl'
3+
import { Codegen } from '../codegen/Codegen'
4+
import { ResponsiveCodegen } from '../codegen/responsive/ResponsiveCodegen'
5+
6+
const runMock = mock(async () => {})
7+
const getComponentsCodesMock = mock(() => [])
8+
const getCodeMock = mock(() => 'base-code')
9+
const generateResponsiveCodeMock = mock(() => {
10+
throw new Error('boom')
11+
})
12+
13+
const originalError = console.error
14+
const consoleErrorMock = mock(() => {})
15+
16+
const resetFigma = () => {
17+
;(globalThis as { figma?: unknown }).figma = undefined
18+
}
19+
20+
const originalRun = Codegen.prototype.run
21+
const originalGetComponentsCodes = Codegen.prototype.getComponentsCodes
22+
const originalGetCode = Codegen.prototype.getCode
23+
const originalGenerateResponsiveCode =
24+
ResponsiveCodegen.prototype.generateResponsiveCode
25+
26+
describe('registerCodegen responsive error handling', () => {
27+
beforeEach(() => {
28+
Codegen.prototype.run = runMock
29+
Codegen.prototype.getComponentsCodes = getComponentsCodesMock
30+
Codegen.prototype.getCode = getCodeMock
31+
ResponsiveCodegen.prototype.generateResponsiveCode =
32+
generateResponsiveCodeMock
33+
34+
console.error = consoleErrorMock as typeof console.error
35+
resetFigma()
36+
})
37+
38+
afterEach(() => {
39+
Codegen.prototype.run = originalRun
40+
Codegen.prototype.getComponentsCodes = originalGetComponentsCodes
41+
Codegen.prototype.getCode = originalGetCode
42+
ResponsiveCodegen.prototype.generateResponsiveCode =
43+
originalGenerateResponsiveCode
44+
45+
console.error = originalError
46+
resetFigma()
47+
mock.restore()
48+
})
49+
50+
test('swallows responsive errors and still returns base code', async () => {
51+
const handlerCalls: Parameters<
52+
Parameters<typeof registerCodegen>[0]['codegen']['on']
53+
>[1][] = []
54+
const ctx = {
55+
editorType: 'dev',
56+
mode: 'codegen',
57+
command: 'noop',
58+
codegen: {
59+
on: mock((_event, handler) => {
60+
handlerCalls.push(handler)
61+
}),
62+
},
63+
} as unknown as typeof figma
64+
65+
const node = {
66+
type: 'FRAME',
67+
name: 'Main',
68+
parent: { type: 'SECTION', name: 'Parent', children: [] },
69+
} as unknown as SceneNode
70+
71+
registerCodegen(ctx)
72+
73+
const generate = handlerCalls[0]
74+
const result = await generate({ node, language: 'devup-ui' })
75+
76+
expect(consoleErrorMock).toHaveBeenCalled()
77+
expect(runMock).toHaveBeenCalled()
78+
expect(result).toEqual([
79+
{
80+
title: 'Main',
81+
language: 'TYPESCRIPT',
82+
code: 'base-code',
83+
},
84+
])
85+
})
86+
})
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
2+
import { BREAKPOINT_ORDER, type BreakpointKey } from '../index'
3+
4+
const getPropsMock = mock(async (node: SceneNode) => ({ id: node.name }))
5+
const renderNodeMock = mock(
6+
(
7+
component: string,
8+
props: Record<string, unknown>,
9+
depth: number,
10+
children: string[],
11+
) =>
12+
`render:${component}:depth=${depth}:${JSON.stringify(props)}|${children.join(';')}`,
13+
)
14+
const getDevupComponentByNodeMock = mock(() => 'Box')
15+
16+
describe('ResponsiveCodegen', () => {
17+
let ResponsiveCodegen: typeof import('../ResponsiveCodegen').ResponsiveCodegen
18+
19+
beforeEach(async () => {
20+
mock.module('../../props', () => ({ getProps: getPropsMock }))
21+
mock.module('../../render', () => ({ renderNode: renderNodeMock }))
22+
mock.module('../../utils/get-devup-component', () => ({
23+
getDevupComponentByNode: getDevupComponentByNodeMock,
24+
}))
25+
26+
;({ ResponsiveCodegen } = await import('../ResponsiveCodegen'))
27+
getPropsMock.mockClear()
28+
renderNodeMock.mockClear()
29+
getDevupComponentByNodeMock.mockClear()
30+
})
31+
32+
afterEach(() => {
33+
mock.restore()
34+
})
35+
36+
const makeNode = (
37+
name: string,
38+
width?: number,
39+
children: SceneNode[] = [],
40+
type: SceneNode['type'] = 'FRAME',
41+
) => {
42+
const node: Record<string, unknown> = { name, children, type }
43+
if (typeof width === 'number') {
44+
node.width = width
45+
}
46+
return node as unknown as SceneNode
47+
}
48+
49+
it('returns message when no responsive variants exist', async () => {
50+
const section = {
51+
type: 'SECTION',
52+
children: [makeNode('no-width', undefined, [])],
53+
} as unknown as SectionNode
54+
55+
const generator = new ResponsiveCodegen(section)
56+
const result = await generator.generateResponsiveCode()
57+
58+
expect(result).toBe('// No responsive variants found in section')
59+
})
60+
61+
it('falls back to single breakpoint generation', async () => {
62+
const child = makeNode('mobile', 320, [makeNode('leaf', undefined, [])])
63+
const section = {
64+
type: 'SECTION',
65+
children: [child],
66+
} as unknown as SectionNode
67+
68+
const generator = new ResponsiveCodegen(section)
69+
const nodeCode = await (
70+
generator as unknown as {
71+
generateNodeCode: (node: SceneNode, depth: number) => Promise<string>
72+
}
73+
).generateNodeCode(child, 0)
74+
expect(renderNodeMock).toHaveBeenCalled()
75+
76+
const result = await generator.generateResponsiveCode()
77+
78+
expect(result.startsWith('render:Box')).toBeTrue()
79+
expect(nodeCode.startsWith('render:Box')).toBeTrue()
80+
})
81+
82+
it('merges breakpoints and adds display for missing child variants', async () => {
83+
const onlyMobile = makeNode('OnlyMobile')
84+
const sharedMobile = makeNode('Shared')
85+
const sharedTablet = makeNode('Shared')
86+
87+
const mobileRoot = makeNode('RootMobile', 320, [onlyMobile, sharedMobile])
88+
const tabletRoot = makeNode('RootTablet', 1000, [sharedTablet])
89+
const section = {
90+
type: 'SECTION',
91+
children: [mobileRoot, tabletRoot],
92+
} as unknown as SectionNode
93+
94+
const generator = new ResponsiveCodegen(section)
95+
const result = await generator.generateResponsiveCode()
96+
97+
expect(getPropsMock).toHaveBeenCalled()
98+
expect(renderNodeMock.mock.calls.length).toBeGreaterThan(0)
99+
expect(result.startsWith('render:Box')).toBeTrue()
100+
})
101+
102+
it('returns empty display when all breakpoints present', async () => {
103+
const section = {
104+
type: 'SECTION',
105+
children: [makeNode('RootMobile', 320)],
106+
} as unknown as SectionNode
107+
const generator = new ResponsiveCodegen(section)
108+
const displayProps = (
109+
generator as unknown as {
110+
getDisplayProps: (
111+
present: Set<BreakpointKey>,
112+
all: Set<BreakpointKey>,
113+
) => Record<string, unknown>
114+
}
115+
).getDisplayProps(
116+
new Set<BreakpointKey>(BREAKPOINT_ORDER),
117+
new Set<BreakpointKey>(BREAKPOINT_ORDER),
118+
)
119+
expect(displayProps).toEqual({})
120+
})
121+
122+
it('recursively generates node code', async () => {
123+
const child = makeNode('child')
124+
const parent = makeNode('parent', undefined, [child])
125+
const section = {
126+
type: 'SECTION',
127+
children: [parent],
128+
} as unknown as SectionNode
129+
const generator = new ResponsiveCodegen(section)
130+
const nodeCode = await (
131+
generator as unknown as {
132+
generateNodeCode: (node: SceneNode, depth: number) => Promise<string>
133+
}
134+
).generateNodeCode(parent, 0)
135+
expect(nodeCode.startsWith('render:Box')).toBeTrue()
136+
expect(renderNodeMock).toHaveBeenCalled()
137+
})
138+
139+
it('static helpers detect section and parent section', () => {
140+
const section = { type: 'SECTION' } as unknown as SectionNode
141+
const frame = { type: 'FRAME', parent: section } as unknown as SceneNode
142+
expect(ResponsiveCodegen.canGenerateResponsive(section)).toBeTrue()
143+
expect(ResponsiveCodegen.hasParentSection(frame)).toEqual(section)
144+
})
145+
})
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it } from 'bun:test'
2+
import {
3+
getBreakpointByWidth,
4+
groupChildrenByBreakpoint,
5+
groupNodesByName,
6+
optimizeResponsiveValue,
7+
} from '../index'
8+
9+
describe('responsive index helpers', () => {
10+
it('maps width to breakpoint boundaries', () => {
11+
expect(getBreakpointByWidth(320)).toBe('mobile')
12+
expect(getBreakpointByWidth(768)).toBe('sm')
13+
expect(getBreakpointByWidth(991)).toBe('tablet')
14+
expect(getBreakpointByWidth(1280)).toBe('lg')
15+
expect(getBreakpointByWidth(1600)).toBe('pc')
16+
})
17+
18+
it('groups children by breakpoint', () => {
19+
const mobileNode = { width: 320 } as unknown as SceneNode
20+
const tabletNode = { width: 900 } as unknown as SceneNode
21+
const groups = groupChildrenByBreakpoint([mobileNode, tabletNode])
22+
23+
expect(groups.get('mobile')).toEqual([mobileNode])
24+
expect(groups.get('tablet')).toEqual([tabletNode])
25+
})
26+
27+
it('optimizes responsive values by collapsing duplicates and trimming', () => {
28+
expect(optimizeResponsiveValue(['200px', '200px', '100px', null])).toEqual([
29+
'200px',
30+
null,
31+
'100px',
32+
])
33+
expect(optimizeResponsiveValue([null, null, null])).toBeNull()
34+
expect(optimizeResponsiveValue(['80px', null, null])).toBe('80px')
35+
})
36+
37+
it('groups nodes by name for responsive matching', () => {
38+
const mobile = { name: 'Header' } as unknown as SceneNode
39+
const tablet = { name: 'Header' } as unknown as SceneNode
40+
const groups = groupNodesByName(
41+
new Map([
42+
['mobile', [mobile]],
43+
['tablet', [tablet]],
44+
]),
45+
)
46+
47+
expect(groups.get('Header')).toEqual([
48+
{ breakpoint: 'mobile', node: mobile, props: {} },
49+
{ breakpoint: 'tablet', node: tablet, props: {} },
50+
])
51+
})
52+
53+
it('handles object equality and empty optimized array', () => {
54+
const obj = { a: 1 }
55+
const optimized = optimizeResponsiveValue([
56+
obj,
57+
{ a: 1 },
58+
null,
59+
null,
60+
null,
61+
null,
62+
null,
63+
null,
64+
null,
65+
null,
66+
])
67+
expect(optimized).toEqual(obj)
68+
})
69+
})

src/codegen/responsive/index.ts

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,6 @@ export function optimizeResponsiveValue(
120120
optimized.pop()
121121
}
122122

123-
// If empty, return null.
124-
if (optimized.length === 0) {
125-
return null
126-
}
127-
128123
// If only index 0 has value, return single value.
129124
if (optimized.length === 1 && optimized[0] !== null) {
130125
return optimized[0]
@@ -192,43 +187,6 @@ export function mergePropsToResponsive(
192187
return result
193188
}
194189

195-
/**
196-
* Build display props for elements that exist only on some breakpoints.
197-
* presentBreakpoints: breakpoints where the element exists.
198-
*/
199-
export function getDisplayPropsForBreakpoints(
200-
presentBreakpoints: Set<BreakpointKey>,
201-
): Props {
202-
if (presentBreakpoints.size === BREAKPOINT_ORDER.length) {
203-
// No display props needed if present everywhere.
204-
return {}
205-
}
206-
207-
const displayValues: (string | null)[] = BREAKPOINT_ORDER.map((bp) =>
208-
presentBreakpoints.has(bp) ? null : 'none',
209-
)
210-
211-
// From the first present breakpoint onward, the element should show.
212-
let foundFirst = false
213-
for (let i = 0; i < BREAKPOINT_ORDER.length; i++) {
214-
if (presentBreakpoints.has(BREAKPOINT_ORDER[i])) {
215-
if (!foundFirst) {
216-
foundFirst = true
217-
}
218-
displayValues[i] = null
219-
} else {
220-
displayValues[i] = 'none'
221-
}
222-
}
223-
224-
// Do not trim trailing nulls here (chakra-ui array rule); if all null, return empty object.
225-
if (displayValues.every((v) => v === null)) {
226-
return {}
227-
}
228-
229-
return { display: displayValues }
230-
}
231-
232190
export interface ResponsiveNodeGroup {
233191
breakpoint: BreakpointKey
234192
node: SceneNode

0 commit comments

Comments
 (0)