Skip to content
Open
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
52 changes: 52 additions & 0 deletions packages/core/css-mediaquery/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,58 @@ describe('css-mediaquery', () => {
});
});

describe('range syntax', () => {
it('parses a comparison with the feature on either side', () => {
expect(parseQuery('(width >= 600px)')[0].features).toEqual([{ modifier: 'min', property: 'width', value: '600px' }]);
expect(parseQuery('(width < 600px)')[0].features).toEqual([{ modifier: 'lt', property: 'width', value: '600px' }]);
expect(parseQuery('(600px < width)')[0].features).toEqual([{ modifier: 'gt', property: 'width', value: '600px' }]);
expect(parseQuery('(600px >= height)')[0].features).toEqual([{ modifier: 'max', property: 'height', value: '600px' }]);
expect(parseQuery('(width = 600px)')[0].features).toEqual([{ modifier: undefined, property: 'width', value: '600px' }]);
});

it('parses a range with both bounds', () => {
expect(parseQuery('(400px <= width < 700px)')[0].features).toEqual([
{ modifier: 'min', property: 'width', value: '400px' },
{ modifier: 'lt', property: 'width', value: '700px' },
]);
expect(parseQuery('(700px > width >= 400px)')[0].features).toEqual([
{ modifier: 'lt', property: 'width', value: '700px' },
{ modifier: 'min', property: 'width', value: '400px' },
]);
});

it('rejects malformed ranges', () => {
expect(() => parseQuery('(400px < width > 700px)')).toThrow(SyntaxError);
expect(() => parseQuery('(400px = width = 700px)')).toThrow(SyntaxError);
expect(() => parseQuery('(400px < 700px)')).toThrow(SyntaxError);
expect(() => parseQuery('(width >= )')).toThrow(SyntaxError);
});

it('matches inclusive and exclusive bounds', () => {
expect(matchQuery('(width >= 600px)', { width: 600 })).toBe(true);
expect(matchQuery('(width > 600px)', { width: 600 })).toBe(false);
expect(matchQuery('(width < 600px)', { width: 599 })).toBe(true);
expect(matchQuery('(width < 600px)', { width: 600 })).toBe(false);
expect(matchQuery('(400px <= width < 700px)', { width: 400 })).toBe(true);
expect(matchQuery('(400px <= width < 700px)', { width: 700 })).toBe(false);
});

it('combines with media types, and, not and lists', () => {
expect(matchQuery('screen and (width >= 600px) and (orientation: portrait)', { type: MediaQueryType.screen, width: 800, orientation: 'portrait' })).toBe(true);
expect(matchQuery('not all and (width >= 600px)', { width: 800 })).toBe(false);
expect(matchQuery('(width < 100px), (height >= 600px)', { width: 800, height: 600 })).toBe(true);
});
});

describe('rem and em lengths', () => {
it('are the initial font size, 16', () => {
expect(matchQuery('(width >= 48rem)', { width: 768 })).toBe(true);
expect(matchQuery('(width >= 48rem)', { width: 767 })).toBe(false);
expect(matchQuery('(max-width: 2em)', { width: 32 })).toBe(true);
expect(matchQuery('(max-width: 2em)', { width: 33 })).toBe(false);
});
});

describe('matchQuery', () => {
describe('Equality check', () => {
it('orientation: should return true for a correct match (===)', () => {
Expand Down
62 changes: 59 additions & 3 deletions packages/core/css-mediaquery/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { Length } from '../ui/styling/length-shared';
const RE_MEDIA_QUERY = /^(?:(only|not)?\s*([_a-z][_a-z0-9-]*)|(\([^\)]+\)))(?:\s*and\s*(.*))?$/i,
RE_MQ_EXPRESSION = /^\(\s*([_a-z-][_a-z0-9-]*)\s*(?:\:\s*([^\)]+))?\s*\)$/,
RE_MQ_FEATURE = /^(?:(min|max)-)?(.+)/,
RE_MQ_RANGE_OPERATOR = /\s*(<=|>=|<|>|=)\s*/,
RE_MQ_NAME = /^[_a-z-][_a-z0-9-]*$/i,
RE_FONT_RELATIVE_LENGTH = /^(-?\d*\.?\d+)\s*r?em$/i,
RE_LENGTH_UNIT = /(em|rem|px|cm|mm|in|pt|pc)?\s*$/,
RE_RESOLUTION_UNIT = /(dpi|dpcm|dppx)?\s*$/;

Expand Down Expand Up @@ -92,7 +95,7 @@ export function matchQuery(mediaQuery: string, values: MediaQueryEnvironmentPara
case 'height':
case 'device-width':
case 'device-height': {
numVal = Length.toDevicePixels(Length.parse(feature.value), 0);
numVal = lengthToDevicePixels(feature.value);
break;
}
default:
Expand All @@ -105,6 +108,10 @@ export function matchQuery(mediaQuery: string, values: MediaQueryEnvironmentPara
return value >= numVal;
case 'max':
return value <= numVal;
case 'gt':
return value > numVal;
case 'lt':
return value < numVal;
default:
return value === numVal;
}
Expand Down Expand Up @@ -157,9 +164,16 @@ export function parseQuery(mediaQuery: string): MediaQueryExpression[] {
for (const feature of features) {
const captures = feature.match(RE_MQ_EXPRESSION);

// Media query must be valid
if (!captures) {
throw new SyntaxError(`Invalid CSS media query feature: '${feature}' on '${query}'`);
const range = parseRangeFeature(feature);

// Media query must be valid
if (!range) {
throw new SyntaxError(`Invalid CSS media query feature: '${feature}' on '${query}'`);
}

expression.features.push(...range);
continue;
}

const featureData = captures[1].toLowerCase().match(RE_MQ_FEATURE);
Expand All @@ -174,3 +188,45 @@ export function parseQuery(mediaQuery: string): MediaQueryExpression[] {
return expression;
});
}

const RANGE_MODIFIERS = { '>=': 'min', '>': 'gt', '<=': 'max', '<': 'lt', '=': undefined };
const FLIPPED_RANGE_OPERATORS = { '>=': '<=', '>': '<', '<=': '>=', '<': '>', '=': '=' };

function parseRangeFeature(feature: string): MediaQueryFeature[] | null {
const parts = feature.slice(1, -1).trim().split(RE_MQ_RANGE_OPERATOR);
const features: MediaQueryFeature[] = [];
const add = (property: string, operator: string, value: string) => {
features.push({ modifier: RANGE_MODIFIERS[operator], property: property.toLowerCase(), value });
};

if (parts.length === 3) {
const [left, operator, right] = parts;
if (RE_MQ_NAME.test(left) && right) {
add(left, operator, right);
} else if (RE_MQ_NAME.test(right) && left) {
add(right, FLIPPED_RANGE_OPERATORS[operator], left);
} else {
return null;
}
} else if (parts.length === 5) {
const [first, firstOperator, name, lastOperator, last] = parts;
if (!RE_MQ_NAME.test(name) || !first || !last || firstOperator === '=' || firstOperator[0] !== lastOperator[0]) {
return null;
}
add(name, FLIPPED_RANGE_OPERATORS[firstOperator], first);
add(name, lastOperator, last);
} else {
return null;
}

return features;
}

function lengthToDevicePixels(value: string): number {
const fontRelative = RE_FONT_RELATIVE_LENGTH.exec(value.trim());
if (fontRelative) {
return Length.toDevicePixels(parseFloat(fontRelative[1]) * 16, 0);
}

return Length.toDevicePixels(Length.parse(value), 0);
}
11 changes: 11 additions & 0 deletions packages/core/ui/styling/css-selector.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,17 @@ describe('css-selector', () => {
expect(imageSelectors[1].ruleset.declarations).toEqual([{ property: 'color', value: 'blue' }]);
});

it('should apply css rules of range media queries', () => {
const { selectorScope } = create(`
@media (width <= ${widthDIPs}) { button { color: red; } }
@media (width > ${widthDIPs}) { button { color: blue; } }
@media (${widthDIPs / 16}rem <= width < ${widthDIPs / 16 + 1}rem) { button { color: green; } }
`);

const { selectors } = selectorScope.query({ cssType: 'button' });
expect(selectors.map((selector) => selector.ruleset.declarations[0].value)).toEqual(['red', 'green']);
});

it('should not apply css rules of non-matching media query', () => {
const { selectorScope } = create(`
@media only screen and (max-width: ${widthDIPs - 1}) {
Expand Down
Loading