Skip to content

Commit

Permalink
feat(editor): add responsive utils (#6716)
Browse files Browse the repository at this point in the history
This prep PR adds the responsive utilities we'll use in our responsive
PR, along with types and tests.
The important utilities (that are exposed outside) are:
1. `extractScreenSizeFromCss(css: string)` - which recieves a CSS string
of the media query (i.e `@media (min-width: 100px)`) and turns it into a
`ScreenSize` object (in our example - `{ min: {value: 100, unit: 'px'}
}`). We also support media ranges (`@media (20px < width < 50em)`). This
function is covered in tests.
It uses `mediaQueryToScreenSize(mediaQuery: MediaQuery)` internally,
which takes a `MediaQuery` object (after it was parsed from the CSS
string), and does the "heavy lifting" of converting it to a `ScreenSize`
representation. This inner function is also covered in tests.

2. `selectValueByBreakpoint` - this function recieves a list of possible
variants (values with corresponding `ScreenSize`s which they apply in),
and the current Scene size. It infers the most matching variant
according to the Scene size (can also be the default variant if none are
matching). This function is also fully covered in tests.

No functionality is added to the app itself, this is just a prep PR for
the functionality in the subsequent PR.

**Manual Tests:**
I hereby swear that:

- [X] I opened a hydrogen project and it loaded
- [X] I could navigate to various routes in Play mode
  • Loading branch information
liady authored Dec 11, 2024
1 parent 0a6ad3f commit accbe3d
Show file tree
Hide file tree
Showing 4 changed files with 433 additions and 0 deletions.
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

0 comments on commit accbe3d

Please sign in to comment.