Skip to content
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

feat(editor): add responsive utils #6716

Merged
merged 9 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions editor/src/components/canvas/canvas-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
CSSPadding,
FlexDirection,
} from '../inspector/common/css-utils'
import type { ScreenSize } from './responsive-types'

export const CanvasContainerID = 'canvas-container'
export const SceneContainerName = 'scene'
Expand Down Expand Up @@ -557,6 +558,13 @@ interface ParsedCSSStyleProperty<T> {
value: T
}

type StyleHoverModifier = { type: 'hover' }
export type StyleMediaSizeModifier = {
type: 'media-size'
size: ScreenSize
}
export type StyleModifier = StyleHoverModifier | StyleMediaSizeModifier

export type CSSStyleProperty<T> =
| CSSStylePropertyNotFound
| CSSStylePropertyNotParsable
Expand Down
39 changes: 39 additions & 0 deletions editor/src/components/canvas/responsive-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Identifier, Dimension } from 'css-tree'
import type { CSSNumber } from '../inspector/common/css-utils'
// @media (min-width: 100px) and (max-width: 200em) => { min: { value: 100, unit: 'px' }, max: { value: 200, unit: 'em' } }
export type ScreenSize = {
min?: CSSNumber
max?: CSSNumber
}

export interface MediaQuery {
type: 'MediaQuery'
loc: null
modifier: null
mediaType: null
condition?: {
type: 'Condition'
loc: null
kind: 'media'
children: Array<FeatureRange | Feature | Identifier>
}
}

export interface FeatureRange {
type: 'FeatureRange'
loc: null
kind: 'media'
left?: Dimension | Identifier
leftComparison: '<' | '>'
middle: Dimension | Identifier
rightComparison: '<' | '>'
right?: Dimension | Identifier
}

export interface Feature {
type: 'Feature'
loc: null
kind: 'media'
name: 'min-width' | 'max-width'
value?: Dimension
}
176 changes: 176 additions & 0 deletions editor/src/components/canvas/responsive-utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import * as csstree from 'css-tree'
import { mediaQueryToScreenSize, selectValueByBreakpoint } from './responsive-utils'
import type { ScreenSize, MediaQuery } from './responsive-types'
import { extractScreenSizeFromCss } from './responsive-utils'
import type { StyleModifier } from './canvas-types'

describe('extractScreenSizeFromCss', () => {
it('extracts screen size from simple media query', () => {
const css = '@media (min-width: 100px) and (max-width: 500px)'
const result = extractScreenSizeFromCss(css)
expect(result).toEqual({
min: { value: 100, unit: 'px' },
max: { value: 500, unit: 'px' },
})
})

it('returns null for invalid media query', () => {
const css = 'not-a-media-query'
const result = extractScreenSizeFromCss(css)
expect(result).toBeNull()
})

it('uses cache for repeated calls with same CSS', () => {
const css = '@media (min-width: 100px)'

// First call
const result1 = extractScreenSizeFromCss(css)
// Second call - should return same object reference
const result2 = extractScreenSizeFromCss(css)

expect(result1).toBe(result2) // Use toBe for reference equality
expect(result1).toEqual({
min: { value: 100, unit: 'px' },
})
})

it('handles different CSS strings independently in cache', () => {
const css1 = '@media (min-width: 100px)'
const css2 = '@media (max-width: 500px)'

// First string
const result1a = extractScreenSizeFromCss(css1)
const result1b = extractScreenSizeFromCss(css1)
expect(result1a).toBe(result1b)
expect(result1a).toEqual({
min: { value: 100, unit: 'px' },
})

// Second string
const result2a = extractScreenSizeFromCss(css2)
const result2b = extractScreenSizeFromCss(css2)
expect(result2a).toBe(result2b)
expect(result2a).toEqual({
max: { value: 500, unit: 'px' },
})

// Different strings should have different references
expect(result1a).not.toBe(result2a)
})
})

describe('selectValueByBreakpoint', () => {
const variants: { value: string; modifiers?: StyleModifier[] }[] = [
{
value: 'Desktop Value',
modifiers: [{ type: 'media-size', size: { min: { value: 200, unit: 'px' } } }],
},
{
value: 'Tablet Value',
modifiers: [{ type: 'media-size', size: { min: { value: 100, unit: 'px' } } }],
},
{
value: 'Extra Large Value',
modifiers: [{ type: 'media-size', size: { min: { value: 20, unit: 'em' } } }],
},
{
value: 'Ranged Value',
modifiers: [
{
type: 'media-size',
size: { min: { value: 80, unit: 'px' }, max: { value: 90, unit: 'px' } },
},
],
},
{
value: 'Mobile Value',
modifiers: [{ type: 'media-size', size: { min: { value: 60, unit: 'px' } } }],
},
{ value: 'Default Value' },
]
const tests: { title: string; screenSize: number; expected: string }[] = [
{ title: 'selects the correct value', screenSize: 150, expected: 'Tablet Value' },
{ title: 'select the closest value', screenSize: 250, expected: 'Desktop Value' },
{ title: 'converts em to px', screenSize: 350, expected: 'Extra Large Value' },
{
title: 'selects the default value if no breakpoint is matched',
screenSize: 50,
expected: 'Default Value',
},
{
title: 'selects the ranged value if the screen size is within the range',
screenSize: 85,
expected: 'Ranged Value',
},
{
title: 'selects the mobile value if the screen size is outside the ranged values',
screenSize: 95,
expected: 'Mobile Value',
},
] as const

tests.forEach((test) => {
it(`${test.title}`, () => {
expect(selectValueByBreakpoint(variants, test.screenSize)?.value).toEqual(test.expected)
})
})

it('selects null if no matching breakpoint and no default value', () => {
const largeVariants: { value: string; modifiers?: StyleModifier[] }[] = [
{
value: 'Desktop Value',
modifiers: [{ type: 'media-size', size: { min: { value: 200, unit: 'px' } } }],
},
{
value: 'Tablet Value',
modifiers: [{ type: 'media-size', size: { min: { value: 100, unit: 'px' } } }],
},
]
expect(selectValueByBreakpoint(largeVariants, 50)).toBeNull()
})
it('selects default value if no media modifiers', () => {
const noMediaVariants: { value: string; modifiers?: StyleModifier[] }[] = [
{
value: 'Hover Value',
modifiers: [{ type: 'hover' }],
},
{ value: 'Default Value' },
]
expect(selectValueByBreakpoint(noMediaVariants, 50)?.value).toEqual('Default Value')
})
})

describe('mediaQueryToScreenSize', () => {
it('converts simple screen size queries', () => {
const testCases: { input: string; expected: ScreenSize }[] = [
{
input: '@media (100px <width < 500px)',
expected: { min: { value: 100, unit: 'px' }, max: { value: 500, unit: 'px' } },
},
{
input: '@media (min-width: 100px) and (max-width: 500px)',
expected: { min: { value: 100, unit: 'px' }, max: { value: 500, unit: 'px' } },
},
{
input: '@media screen and (min-width: 100px)',
expected: { min: { value: 100, unit: 'px' } },
},
{
input: '@media (100px < width) and (max-width: 500px)',
expected: { min: { value: 100, unit: 'px' }, max: { value: 500, unit: 'px' } },
},
{
input: '@media (width > 100px)',
expected: { min: { value: 100, unit: 'px' } },
},
]
testCases.forEach((testCase) => {
csstree.walk(csstree.parse(testCase.input), (node) => {
if (node.type === 'MediaQuery') {
const result = mediaQueryToScreenSize(node as unknown as MediaQuery)
expect(result).toEqual(testCase.expected)
}
})
})
})
})
Loading
Loading