diff --git a/package-lock.json b/package-lock.json index fa08090..e044992 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31592,7 +31592,6 @@ "@babel/helper-module-imports": "^7.24.7", "@stylexjs/babel-plugin": "^0.9.3", "@stylexjs/stylex": "^0.9.3", - "css-mediaquery": "0.1.2", "postcss-value-parser": "^4.1.0" }, "devDependencies": { diff --git a/packages/react-strict-dom/package.json b/packages/react-strict-dom/package.json index 019dd87..6489347 100644 --- a/packages/react-strict-dom/package.json +++ b/packages/react-strict-dom/package.json @@ -30,7 +30,6 @@ "@babel/helper-module-imports": "^7.24.7", "@stylexjs/babel-plugin": "^0.9.3", "@stylexjs/stylex": "^0.9.3", - "css-mediaquery": "0.1.2", "postcss-value-parser": "^4.1.0" }, "devDependencies": { diff --git a/packages/react-strict-dom/src/native/modules/__tests__/mediaQuery-test.js b/packages/react-strict-dom/src/native/modules/__tests__/mediaQuery-test.js new file mode 100644 index 0000000..35c1b64 --- /dev/null +++ b/packages/react-strict-dom/src/native/modules/__tests__/mediaQuery-test.js @@ -0,0 +1,427 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +import { mediaQuery } from '../mediaQuery'; + +describe('mediaQuery.parse()', function () { + test('should parse media queries without expressions', function () { + expect(mediaQuery.parse('screen')).toEqual([ + { + inverse: false, + type: 'screen', + expressions: [] + } + ]); + + expect(mediaQuery.parse('not screen')).toEqual([ + { + inverse: true, + type: 'screen', + expressions: [] + } + ]); + }); + + test('should parse common retina media query list', function () { + const parsed = mediaQuery.parse( + 'only screen and (-webkit-min-device-pixel-ratio: 2),\n' + + 'only screen and (min--moz-device-pixel-ratio: 2),\n' + + 'only screen and (-o-min-device-pixel-ratio: 2/1),\n' + + 'only screen and (min-device-pixel-ratio: 2),\n' + + 'only screen and (min-resolution: 192dpi),\n' + + 'only screen and (min-resolution: 2dppx)' + ); + + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(6); + expect(parsed[0].expressions[0].feature).toEqual( + '-webkit-min-device-pixel-ratio' + ); + expect(parsed[1].expressions[0].modifier).toEqual('min'); + }); + + test('should throw a SyntaxError when a media query is invalid', function () { + function parse(query) { + return function () { + mediaQuery.parse(query); + }; + } + + expect(parse('some crap')).toThrow(SyntaxError); + expect(parse('48em')).toThrow(SyntaxError); + expect(parse('screen and crap')).toThrow(SyntaxError); + expect(parse('screen and (48em)')).toThrow(SyntaxError); + expect(parse('screen and (foo:)')).toThrow(SyntaxError); + expect(parse('()')).toThrow(SyntaxError); + expect(parse('(foo) (bar)')).toThrow(SyntaxError); + expect(parse('(foo:) and (bar)')).toThrow(SyntaxError); + }); +}); + +describe('mediaQuery.match()', function () { + describe('equality check', function () { + test('Orientation: should return true for a correct match (===)', function () { + expect( + mediaQuery.match('(orientation: portrait)', { orientation: 'portrait' }) + ).toBe(true); + }); + + test('orientation: should return false for an incorrect match (===)', function () { + expect( + mediaQuery.match('(orientation: landscape)', { + orientation: 'portrait' + }) + ).toBe(false); + }); + + test('scan: should return true for a correct match (===)', function () { + expect( + mediaQuery.match('(scan: progressive)', { scan: 'progressive' }) + ).toBe(true); + }); + + test('scan: should return false for an incorrect match (===)', function () { + expect( + mediaQuery.match('(scan: progressive)', { scan: 'interlace' }) + ).toBe(false); + }); + + test('width: should return true for a correct match', function () { + expect(mediaQuery.match('(width: 800px)', { width: 800 })).toBe(true); + }); + + test('width: should return false for an incorrect match', function () { + expect(mediaQuery.match('(width: 800px)', { width: 900 })).toBe(false); + }); + }); + + describe('length check', function () { + describe('width', function () { + test('should return true for a width higher than a min-width', function () { + expect(mediaQuery.match('(min-width: 48em)', { width: '80em' })).toBe( + true + ); + }); + + test('should return false for a width lower than a min-width', function () { + expect(mediaQuery.match('(min-width: 48em)', { width: '20em' })).toBe( + false + ); + }); + + test('should return false when no width value is specified', function () { + expect(mediaQuery.match('(min-width: 48em)', { resolution: 72 })).toBe( + false + ); + }); + }); + + describe('different units', function () { + test('should work with ems', function () { + expect(mediaQuery.match('(min-width: 500px)', { width: '48em' })).toBe( + true + ); + }); + + test('should work with rems', function () { + expect(mediaQuery.match('(min-width: 500px)', { width: '48rem' })).toBe( + true + ); + }); + + test('should work with cm', function () { + expect( + mediaQuery.match('(max-height: 1000px)', { height: '20cm' }) + ).toBe(true); + }); + + test('should work with mm', function () { + expect( + mediaQuery.match('(max-height: 1000px)', { height: '200mm' }) + ).toBe(true); + }); + + test('should work with inch', function () { + expect( + mediaQuery.match('(max-height: 1000px)', { height: '20in' }) + ).toBe(false); + }); + + test('should work with pt', function () { + expect( + mediaQuery.match('(max-height: 1000px)', { height: '850pt' }) + ).toBe(false); + }); + + test('should work with pc', function () { + expect( + mediaQuery.match('(max-height: 1000px)', { height: '60pc' }) + ).toBe(true); + }); + }); + }); + + describe('resolution check', function () { + test('should return true for a resolution match', function () { + expect(mediaQuery.match('(resolution: 50dpi)', { resolution: 50 })).toBe( + true + ); + }); + + test('should return true for a resolution higher than a min-resolution', function () { + expect( + mediaQuery.match('(min-resolution: 50dpi)', { resolution: 72 }) + ).toBe(true); + }); + + test('should return false for a resolution higher than a max-resolution', function () { + expect( + mediaQuery.match('(max-resolution: 72dpi)', { resolution: 300 }) + ).toBe(false); + }); + + test('should return false if resolution isnt passed in', function () { + expect(mediaQuery.match('(min-resolution: 72dpi)', { width: 300 })).toBe( + false + ); + }); + + test('should convert units properly', function () { + expect( + mediaQuery.match('(min-resolution: 72dpi)', { resolution: '75dpcm' }) + ).toBe(false); + + expect( + mediaQuery.match('(resolution: 192dpi)', { resolution: '2dppx' }) + ).toBe(true); + }); + }); + + describe('aspect-ratio check', function () { + test('should return true for an aspect-ratio higher than a min-aspect-ratio', function () { + expect( + mediaQuery.match('(min-aspect-ratio: 4/3)', { + 'aspect-ratio': '16 / 9' + }) + ).toBe(true); + }); + + test('should return false for an aspect-ratio higher than a max-aspect-ratio', function () { + expect( + mediaQuery.match('(max-aspect-ratio: 4/3)', { 'aspect-ratio': '16/9' }) + ).toBe(false); + }); + + test('should return false if aspect-ratio isnt passed in', function () { + expect( + mediaQuery.match('(max-aspect-ratio: 72dpi)', { width: 300 }) + ).toBe(false); + }); + + test('should work numbers', function () { + expect( + mediaQuery.match('(min-aspect-ratio: 2560/1440)', { + 'aspect-ratio': 4 / 3 + }) + ).toBe(false); + }); + }); + + describe('grid/color/color-index/monochrome', function () { + test('should return true for a correct match', function () { + expect(mediaQuery.match('(grid)', { grid: 1 })).toBe(true); + + expect(mediaQuery.match('(color)', { color: 1 })).toBe(true); + + expect(mediaQuery.match('(color-index: 3)', { 'color-index': 3 })).toBe( + true + ); + + expect(mediaQuery.match('(monochrome)', { monochrome: 1 })).toBe(true); + }); + + test('should return false for an incorrect match', function () { + expect(mediaQuery.match('(grid)', { grid: 0 })).toBe(false); + + expect(mediaQuery.match('(color)', { color: 0 })).toBe(false); + + expect(mediaQuery.match('(color-index: 3)', { 'color-index': 2 })).toBe( + false + ); + + expect(mediaQuery.match('(monochrome)', { monochrome: 0 })).toBe(false); + + expect(mediaQuery.match('(monochrome)', { monochrome: 'foo' })).toBe( + false + ); + }); + }); + + describe('type', function () { + test('should return true for a correct match', function () { + expect(mediaQuery.match('screen', { type: 'screen' })).toBe(true); + }); + + test('should return false for an incorrect match', function () { + expect( + mediaQuery.match('screen and (color:1)', { + type: 'tv', + color: 1 + }) + ).toBe(false); + }); + + test('should return false for a media query without a type when type is specified in the value object', function () { + expect(mediaQuery.match('(min-width: 500px)', { type: 'screen' })).toBe( + false + ); + }); + + test('should return true for a media query without a type when type is not specified in the value object', function () { + expect(mediaQuery.match('(min-width: 500px)', { width: 700 })).toBe(true); + }); + }); + + describe('not', function () { + test('should return false when theres a match on a `not` query', function () { + expect( + mediaQuery.match('not screen and (color)', { + type: 'screen', + color: 1 + }) + ).toBe(false); + }); + + test('should not disrupt an OR query', function () { + expect( + mediaQuery.match( + 'not screen and (color), screen and (min-height: 48em)', + { + type: 'screen', + height: 1000 + } + ) + ).toBe(true); + }); + + test('should return false for when type === all', function () { + expect( + mediaQuery.match('not all and (min-width: 48em)', { + type: 'all', + width: 1000 + }) + ).toBe(false); + }); + + test('should return true for inverted value', function () { + expect( + mediaQuery.match('not screen and (min-width: 48em)', { width: '24em' }) + ).toBe(true); + }); + }); + + describe('mediaQuery.match() Integration Tests', function () { + describe('real world use cases (mostly AND)', function () { + test('should return true because of width and type match', function () { + expect( + mediaQuery.match('screen and (min-width: 767px)', { + type: 'screen', + width: 980 + }) + ).toBe(true); + }); + + test('should return true because of width is within bounds', function () { + expect( + mediaQuery.match( + 'screen and (min-width: 767px) and (max-width: 979px)', + { + type: 'screen', + width: 800 + } + ) + ).toBe(true); + }); + + test('should return false because width is out of bounds', function () { + expect( + mediaQuery.match( + 'screen and (min-width: 767px) and (max-width: 979px)', + { + type: 'screen', + width: 980 + } + ) + ).toBe(false); + }); + + test('should return false since monochrome is not specified', function () { + expect( + mediaQuery.match('screen and (monochrome)', { width: 980 }) + ).toBe(false); + }); + + test('should return true since color > 0', function () { + expect( + mediaQuery.match('screen and (color)', { + type: 'screen', + color: 1 + }) + ).toBe(true); + }); + + test('should return false since color = 0', function () { + expect( + mediaQuery.match('screen and (color)', { + type: 'screen', + color: 0 + }) + ).toBe(false); + }); + }); + + describe('grouped media queries (OR)', function () { + test('should return true because of color', function () { + expect( + mediaQuery.match( + 'screen and (min-width: 767px), screen and (color)', + { + type: 'screen', + color: 1 + } + ) + ).toBe(true); + }); + + test('should return true because of width and type', function () { + expect( + mediaQuery.match( + 'screen and (max-width: 1200px), handheld and (monochrome)', + { + type: 'screen', + width: 1100 + } + ) + ).toBe(true); + }); + + test('should return false because of monochrome mis-match', function () { + expect( + mediaQuery.match( + 'screen and (max-width: 1200px), handheld and (monochrome)', + { + type: 'screen', + monochrome: 0 + } + ) + ).toBe(false); + }); + }); + }); +}); diff --git a/packages/react-strict-dom/src/native/modules/mediaQuery.js b/packages/react-strict-dom/src/native/modules/mediaQuery.js new file mode 100644 index 0000000..a13b7da --- /dev/null +++ b/packages/react-strict-dom/src/native/modules/mediaQuery.js @@ -0,0 +1,251 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * Modified source: https://github.com/ericf/css-mediaquery + * Copyright (c) 2014, Yahoo! Inc. All rights reserved. + * Copyrights licensed under the New BSD License. + * See the accompanying LICENSE file for terms. + * https://github.com/ericf/css-mediaquery/blob/master/LICENSE + * + * @noflow + */ + +/** + * Parses and determines if a given CSS Media Query matches a set of values via + * JavaScript. This package has two exports: `parse()` and `match()`. + * + * **Matching** + * + * The `match()` method lets you compare a media query expression with a JavaScript + * object and determine if a media query matches a given set of values. + * + * const isMatch = mediaQuery.match(mediaQueryString, stateObject); + * + * A `type` value must be included in the state and it can't be "all". + * + * Parsing + * + * You can parse a media query expression and get an AST back by using the + * `parse()` method. + * + * const ast = mediaQuery.parse(mediaQueryString); + * + * The AST has the following shape: + * + * [{ + * inverse: false, + * type: 'screen', + * expressions: [{ + * modifier: 'min', + * feature : 'width', + * value : '48em' + * }] + * }] + */ + +'use strict'; + +// ----------------------------------------------------------------------------- + +const RE_MEDIA_QUERY = + /^(?:(only|not)?\s*([_a-z][_a-z0-9-]*)|(\([^)]+\)))(?:\s*and\s*(.*))?$/i; +const RE_MQ_EXPRESSION = /^\(\s*([_a-z-][_a-z0-9-]*)\s*(?::\s*([^)]+))?\s*\)$/; +const RE_MQ_FEATURE = /^(?:(min|max)-)?(.+)/; +const RE_LENGTH_UNIT = /(em|rem|px|cm|mm|in|pt|pc)?\s*$/; +const RE_RESOLUTION_UNIT = /(dpi|dpcm|dppx)?\s*$/; +const RE_EXPRESSION = /\([^)]+\)/g; + +function matchQuery(mediaQuery, values) { + return parseQuery(mediaQuery).some(function (query) { + const inverse = query.inverse; + + // Either the parsed or specified `type` is "all", or the types must be + // equal for a match. + const typeMatch = query.type === 'all' || values.type === query.type; + + // Quit early when `type` doesn't match, but take "not" into account. + if ((typeMatch && inverse) || !(typeMatch || inverse)) { + return false; + } + + const expressionsMatch = query.expressions.every(function (expression) { + const feature = expression.feature; + const modifier = expression.modifier; + let expValue = expression.value; + let value = values[feature]; + + // Missing or falsy values don't match. + if (!value && value !== 0) { + return false; + } + + switch (feature) { + case 'orientation': + case 'scan': + return value.toLowerCase() === expValue.toLowerCase(); + + case 'width': + case 'height': + case 'device-width': + case 'device-height': + expValue = toPx(expValue); + value = toPx(value); + break; + + case 'resolution': + expValue = toDpi(expValue); + value = toDpi(value); + break; + + case 'aspect-ratio': + case 'device-aspect-ratio': + expValue = toDecimal(expValue); + value = toDecimal(value); + break; + + case 'grid': + case 'color': + case 'color-index': + case 'monochrome': + expValue = parseInt(expValue, 10) || 1; + value = parseInt(value, 10) || 0; + break; + + default: + break; + } + + switch (modifier) { + case 'min': + return value >= expValue; + case 'max': + return value <= expValue; + default: + return value === expValue; + } + }); + + return (expressionsMatch && !inverse) || (!expressionsMatch && inverse); + }); +} + +const memoizedValues = new Map(); + +function parseQuery(mediaQuery) { + const memoizedValue = memoizedValues.get(mediaQuery); + if (memoizedValue != null) { + return memoizedValue; + } + + const parsedQuery = mediaQuery.split(',').map(function (query) { + query = query.trim(); + + const captures = query.match(RE_MEDIA_QUERY); + + // Media Query must be valid. + if (!captures) { + throw new SyntaxError('Invalid CSS media query: "' + query + '"'); + } + + const modifier = captures[1]; + const type = captures[2]; + let expressions = ((captures[3] || '') + (captures[4] || '')).trim(); + const parsed = {}; + + parsed.inverse = !!modifier && modifier.toLowerCase() === 'not'; + parsed.type = type ? type.toLowerCase() : 'all'; + + // Check for media query expressions. + if (!expressions) { + parsed.expressions = []; + return parsed; + } + + // Split expressions into a list. + expressions = expressions.match(RE_EXPRESSION); + + // Media Query must be valid. + if (!expressions) { + throw new SyntaxError('Invalid CSS media query: "' + query + '"'); + } + + parsed.expressions = expressions.map(function (expression) { + const captures = expression.match(RE_MQ_EXPRESSION); + + // Media Query must be valid. + if (!captures) { + throw new SyntaxError('Invalid CSS media query: "' + query + '"'); + } + + const feature = captures[1].toLowerCase().match(RE_MQ_FEATURE); + + return { + modifier: feature[1], + feature: feature[2], + value: captures[2] + }; + }); + + return parsed; + }); + + memoizedValues.set(mediaQuery, parsedQuery); + return parsedQuery; +} + +// -- Utilities ---------------------------------------------------------------- + +function toDecimal(ratio) { + let decimal = Number(ratio), + numbers; + + if (!decimal) { + numbers = ratio.match(/^(\d+)\s*\/\s*(\d+)$/); + decimal = numbers[1] / numbers[2]; + } + + return decimal; +} + +function toDpi(resolution) { + const value = parseFloat(resolution), + units = String(resolution).match(RE_RESOLUTION_UNIT)[1]; + + switch (units) { + case 'dpcm': + return value / 2.54; + case 'dppx': + return value * 96; + default: + return value; + } +} + +function toPx(length) { + const value = parseFloat(length), + units = String(length).match(RE_LENGTH_UNIT)[1]; + + switch (units) { + case 'em': + return value * 16; + case 'rem': + return value * 16; + case 'cm': + return (value * 96) / 2.54; + case 'mm': + return (value * 96) / 2.54 / 10; + case 'in': + return value * 96; + case 'pt': + return value * 72; + case 'pc': + return (value * 72) / 12; + default: + return value; + } +} + +export const mediaQuery = { match: matchQuery, parse: parseQuery }; diff --git a/tools/flow-typed/css-mediaquery.js b/packages/react-strict-dom/src/native/modules/mediaQuery.js.flow similarity index 76% rename from tools/flow-typed/css-mediaquery.js rename to packages/react-strict-dom/src/native/modules/mediaQuery.js.flow index 57cbbc1..bce23e6 100644 --- a/tools/flow-typed/css-mediaquery.js +++ b/packages/react-strict-dom/src/native/modules/mediaQuery.js.flow @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow + * @flow strict */ type Values = $ReadOnly<{ @@ -16,8 +16,6 @@ type Values = $ReadOnly<{ direction?: 'ltr' | 'rtl' }>; -declare module 'css-mediaquery' { - declare module.exports: { - match: (string, Values) => boolean - }; -} +declare export const mediaQuery : { + match: (mediaQuery: string, values: Values) => boolean +}; diff --git a/packages/react-strict-dom/src/native/stylex/mediaQueryMatches.js b/packages/react-strict-dom/src/native/stylex/mediaQueryMatches.js index 66cee16..53c1d8d 100644 --- a/packages/react-strict-dom/src/native/stylex/mediaQueryMatches.js +++ b/packages/react-strict-dom/src/native/stylex/mediaQueryMatches.js @@ -7,7 +7,7 @@ * @flow strict */ -import mediaQuery from 'css-mediaquery'; +import { mediaQuery } from '../modules/mediaQuery'; const MEDIA = '@media'; @@ -18,8 +18,8 @@ export function mediaQueryMatches( ): boolean { const q = query.split(MEDIA)[1]; return mediaQuery.match(q, { - width, - height, + width: width, + height: height, orientation: width > height ? 'landscape' : 'portrait', 'aspect-ratio': width / height }); diff --git a/packages/react-strict-dom/tools/rollup.config.mjs b/packages/react-strict-dom/tools/rollup.config.mjs index 114dd29..0bd0f5a 100644 --- a/packages/react-strict-dom/tools/rollup.config.mjs +++ b/packages/react-strict-dom/tools/rollup.config.mjs @@ -39,7 +39,7 @@ const sharedPlugins = [ babelPlugin, ossLicensePlugin(), resolve(), - commonjs(), // commonjs packages: styleq and css-mediaquery + commonjs(), // commonjs packages: postcss-value-parser, styleq ]; /** diff --git a/patches/css-mediaquery+0.1.2.patch b/patches/css-mediaquery+0.1.2.patch deleted file mode 100644 index 675a444..0000000 --- a/patches/css-mediaquery+0.1.2.patch +++ /dev/null @@ -1,41 +0,0 @@ -diff --git a/node_modules/css-mediaquery/index.js b/node_modules/css-mediaquery/index.js -index b1d9b0d..1dfc90c 100644 ---- a/node_modules/css-mediaquery/index.js -+++ b/node_modules/css-mediaquery/index.js -@@ -37,7 +37,7 @@ function matchQuery(mediaQuery, values) { - value = values[feature]; - - // Missing or falsy values don't match. -- if (!value) { return false; } -+ if (!value && value !== 0) { return false; } - - switch (feature) { - case 'orientation': -@@ -84,8 +84,15 @@ function matchQuery(mediaQuery, values) { - }); - } - -+var memoizedValues = new Map(); -+ - function parseQuery(mediaQuery) { -- return mediaQuery.split(',').map(function (query) { -+ var memoizedValue = memoizedValues.get(mediaQuery); -+ if (memoizedValue != null) { -+ return memoizedValue; -+ } -+ -+ var parsedQuery = mediaQuery.split(',').map(function (query) { - query = query.trim(); - - var captures = query.match(RE_MEDIA_QUERY), -@@ -113,6 +120,10 @@ function parseQuery(mediaQuery) { - - return parsed; - }); -+ -+ memoizedValues.set(mediaQuery, parsedQuery); -+ -+ return parsedQuery; - } - - // -- Utilities ----------------------------------------------------------------