Skip to content

Commit e657954

Browse files
committed
implement container queries
1 parent dd574c9 commit e657954

File tree

6 files changed

+451
-2
lines changed

6 files changed

+451
-2
lines changed

jest/custom-matchers.js

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
const prettier = require('prettier')
2+
const { diff } = require('jest-diff')
3+
4+
function format(input) {
5+
return prettier.format(input, {
6+
parser: 'css',
7+
printWidth: 100,
8+
})
9+
}
10+
11+
expect.extend({
12+
// Compare two CSS strings with all whitespace removed
13+
// This is probably naive but it's fast and works well enough.
14+
toMatchCss(received, argument) {
15+
function stripped(str) {
16+
return str.replace(/\s/g, '').replace(/;/g, '')
17+
}
18+
19+
const options = {
20+
comment: 'stripped(received) === stripped(argument)',
21+
isNot: this.isNot,
22+
promise: this.promise,
23+
}
24+
25+
const pass = stripped(received) === stripped(argument)
26+
27+
const message = pass
28+
? () => {
29+
return (
30+
this.utils.matcherHint('toMatchCss', undefined, undefined, options) +
31+
'\n\n' +
32+
`Expected: not ${this.utils.printExpected(format(received))}\n` +
33+
`Received: ${this.utils.printReceived(format(argument))}`
34+
)
35+
}
36+
: () => {
37+
const actual = format(received)
38+
const expected = format(argument)
39+
40+
const diffString = diff(expected, actual, {
41+
expand: this.expand,
42+
})
43+
44+
return (
45+
this.utils.matcherHint('toMatchCss', undefined, undefined, options) +
46+
'\n\n' +
47+
(diffString && diffString.includes('- Expect')
48+
? `Difference:\n\n${diffString}`
49+
: `Expected: ${this.utils.printExpected(expected)}\n` +
50+
`Received: ${this.utils.printReceived(actual)}`)
51+
)
52+
}
53+
54+
return { actual: received, message, pass }
55+
},
56+
toIncludeCss(received, argument) {
57+
function stripped(str) {
58+
return str.replace('/* prettier-ignore */', '').replace(/\s/g, '').replace(/;/g, '')
59+
}
60+
61+
const options = {
62+
comment: 'stripped(received).includes(stripped(argument))',
63+
isNot: this.isNot,
64+
promise: this.promise,
65+
}
66+
67+
const pass = stripped(received).includes(stripped(argument))
68+
69+
const message = pass
70+
? () => {
71+
return (
72+
this.utils.matcherHint('toIncludeCss', undefined, undefined, options) +
73+
'\n\n' +
74+
`Expected: not ${this.utils.printExpected(format(received))}\n` +
75+
`Received: ${this.utils.printReceived(format(argument))}`
76+
)
77+
}
78+
: () => {
79+
const actual = format(received)
80+
const expected = format(argument)
81+
82+
const diffString = diff(expected, actual, {
83+
expand: this.expand,
84+
})
85+
86+
return (
87+
this.utils.matcherHint('toIncludeCss', undefined, undefined, options) +
88+
'\n\n' +
89+
(diffString && diffString.includes('- Expect')
90+
? `Difference:\n\n${diffString}`
91+
: `Expected: ${this.utils.printExpected(expected)}\n` +
92+
`Received: ${this.utils.printReceived(actual)}`)
93+
)
94+
}
95+
96+
return { actual: received, message, pass }
97+
},
98+
})
99+
100+
expect.extend({
101+
// Compare two CSS strings with all whitespace removed
102+
// This is probably naive but it's fast and works well enough.
103+
toMatchFormattedCss(received = '', argument = '') {
104+
function format(input) {
105+
return prettier.format(input.replace(/\n/g, ''), {
106+
parser: 'css',
107+
printWidth: 100,
108+
})
109+
}
110+
const options = {
111+
comment: 'stripped(received) === stripped(argument)',
112+
isNot: this.isNot,
113+
promise: this.promise,
114+
}
115+
116+
let formattedReceived = format(received)
117+
let formattedArgument = format(argument)
118+
119+
const pass = formattedReceived === formattedArgument
120+
121+
const message = pass
122+
? () => {
123+
return (
124+
this.utils.matcherHint('toMatchCss', undefined, undefined, options) +
125+
'\n\n' +
126+
`Expected: not ${this.utils.printExpected(formattedReceived)}\n` +
127+
`Received: ${this.utils.printReceived(formattedArgument)}`
128+
)
129+
}
130+
: () => {
131+
const actual = formattedReceived
132+
const expected = formattedArgument
133+
134+
const diffString = diff(expected, actual, {
135+
expand: this.expand,
136+
})
137+
138+
return (
139+
this.utils.matcherHint('toMatchCss', undefined, undefined, options) +
140+
'\n\n' +
141+
(diffString && diffString.includes('- Expect')
142+
? `Difference:\n\n${diffString}`
143+
: `Expected: ${this.utils.printExpected(expected)}\n` +
144+
`Received: ${this.utils.printReceived(actual)}`)
145+
)
146+
}
147+
148+
return { actual: received, message, pass }
149+
},
150+
})

jest/run.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import path from 'path'
2+
import postcss from 'postcss'
3+
import tailwind from 'tailwindcss'
4+
import containerQueries from '~/src'
5+
6+
export let css = String.raw
7+
export let html = String.raw
8+
export let javascript = String.raw
9+
10+
export function run(input, config, plugin = tailwind) {
11+
let { currentTestName } = expect.getState()
12+
13+
config.plugins ??= []
14+
if (!config.plugins.includes(containerQueries)) {
15+
config.plugins.push(containerQueries)
16+
}
17+
18+
return postcss(plugin(config)).process(input, {
19+
from: `${path.resolve(__filename)}?test=${currentTestName}`,
20+
})
21+
}

package.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,35 @@
77
"publishConfig": {
88
"access": "public"
99
},
10+
"files": [
11+
"./src/index.d.ts",
12+
"./src/index.js"
13+
],
1014
"prettier": {
1115
"printWidth": 100,
1216
"semi": false,
1317
"singleQuote": true,
1418
"trailingComma": "es5"
1519
},
20+
"jest": {
21+
"setupFilesAfterEnv": [
22+
"<rootDir>/jest/custom-matchers.js"
23+
],
24+
"transform": {
25+
"\\.js$": "@swc/jest"
26+
},
27+
"moduleNameMapper": {
28+
"^~/(.*)": "<rootDir>/$1"
29+
}
30+
},
1631
"peerDependencies": {
1732
"tailwindcss": ">=3.2.0"
33+
},
34+
"devDependencies": {
35+
"@swc/core": "^1.3.7",
36+
"@swc/jest": "^0.2.23",
37+
"jest": "^29.1.2",
38+
"prettier": "^2.7.1",
39+
"tailwindcss": "^0.0.0-insiders.4338849"
1840
}
1941
}

src/index.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
declare function plugin(options?: {}): Function
2+
export = plugin

src/index.js

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,104 @@
11
const plugin = require('tailwindcss/plugin')
2+
const { normalize } = require('tailwindcss/lib/util/dataTypes')
23

3-
const containerQueries = plugin(function ({ matchVariant }) {
4-
// TODO
4+
const containerQueries = plugin(function ({ matchVariant, theme }) {
5+
let values = theme('containers')
6+
7+
/**
8+
* @param {string} value
9+
*/
10+
function parseValue(value) {
11+
// _ -> space
12+
value = normalize(value)
13+
14+
// If just a number then it's a min-width
15+
let numericValue = value.match(/^(\d+\.\d+|\d+|\.\d+)\D+/)?.[1] ?? null
16+
if (numericValue !== null) {
17+
value = `(min-width: ${value})`
18+
}
19+
20+
// Support for shorthand syntax(es)
21+
// Pending change on extractor ignoring @[min-w:stuff]
22+
// value = value.replace(/^min-w:(.*)$/, '(min-width:\1)')
23+
// value = value.replace(/^max-h:(.*)$/, '(max-width:\1)')
24+
// value = value.replace(/^min-y:(.*)$/, '(min-height:\1)')
25+
// value = value.replace(/^max-h:(.*)$/, '(max-height:\1)')
26+
27+
// If it doesn't start / end with parens then it's not valid (for now)
28+
if (!value.startsWith('(') || !value.endsWith(')')) {
29+
return null
30+
}
31+
32+
// Parse the value into {minX, minY, maxX, maxY, raw} values
33+
// This will make suring simpler
34+
let minX = value.match(/min-width:\s*(\d+\.\d+|\d+|\.\d+)\D+/)?.[1] ?? null
35+
let maxX = value.match(/max-width:\s*(\d+\.\d+|\d+|\.\d+)\D+/)?.[1] ?? null
36+
let minY = value.match(/min-height:\s*(\d+\.\d+|\d+|\.\d+)\D+/)?.[1] ?? null
37+
let maxY = value.match(/max-height:\s*(\d+\.\d+|\d+|\.\d+)\D+/)?.[1] ?? null
38+
39+
let minXf = minX === null ? Number.MIN_SAFE_INTEGER : parseFloat(minX)
40+
let maxXf = maxX === null ? Number.MAX_SAFE_INTEGER : parseFloat(maxX)
41+
let minYf = minY === null ? Number.MIN_SAFE_INTEGER : parseFloat(minY)
42+
let maxYf = maxY === null ? Number.MAX_SAFE_INTEGER : parseFloat(maxY)
43+
44+
return {
45+
raw: value,
46+
parsable: minX !== null || maxX !== null || minY !== null || maxY !== null,
47+
minX: minXf,
48+
maxX: maxXf,
49+
minY: minYf,
50+
maxY: maxYf,
51+
}
52+
}
53+
54+
matchVariant(
55+
'@',
56+
({ value = '', modifier }) => {
57+
let parsed = parseValue(value)
58+
59+
return parsed !== null ? `@container ${modifier ?? ''} ${parsed.raw}` : []
60+
},
61+
{
62+
values,
63+
sort(aVariant, bVariant) {
64+
let a = parseValue(aVariant.value)
65+
let b = parseValue(bVariant.value)
66+
67+
/** @type {string} */
68+
let aLabel = aVariant.modifier ?? ''
69+
70+
/** @type {string} */
71+
let bLabel = bVariant.modifier ?? ''
72+
73+
// Put "raw" values at the end
74+
if (a.parsable === false && b.parsable === false) {
75+
return 0
76+
} else if (a.parsable === false) {
77+
return 1
78+
} else if (b.parsable === false) {
79+
return -1
80+
}
81+
82+
// Order by min width / height and max width / height
83+
let order = a.minX - b.minX || a.minY - b.minY || b.maxX - a.maxX || b.maxY - a.maxY
84+
if (order !== 0) {
85+
return order
86+
}
87+
88+
// Explicitly move empty labels to the end
89+
if (aLabel === '' && bLabel !== '') {
90+
return 1
91+
} else if (aLabel !== '' && bLabel === '') {
92+
return -1
93+
}
94+
95+
// Sort labels alphabetically in the English locale
96+
// We are intentionally overriding the locale because we do not want the sort to
97+
// be affected by the machine's locale (be it a developer or CI environment)
98+
return aLabel.localeCompare(bLabel, 'en', { numeric: true })
99+
},
100+
}
101+
)
5102
})
6103

7104
module.exports = containerQueries

0 commit comments

Comments
 (0)