mirror of https://github.com/sveltejs/svelte
feat: `container` query support via `css-tree` extension (#8275)
Closes #6969 As discussed there, container query support is quite useful to add to Svelte as it is now broadly available with Firefox releasing support imminently w/ FF v110 this upcoming week (~Feb 14th). Chrome has had support since ~Aug '22. The central issue is that css-tree which is a dependency for CSS AST parsing is significantly lagging behind on adding more recent features such as container query support. Ample time has been given to the maintainer to update css-tree and I do have every confidence that in time css-tree will receive a new major version with all sorts of modern CSS syntax supported including container queries. This PR provides an interim solution for what Svelte needs to support container queries now.pull/8425/head
parent
d49b568019
commit
91e8dfcd6d
@ -0,0 +1,43 @@
|
||||
// @ts-nocheck
|
||||
// Note: Must import from the `css-tree` browser bundled distribution due to `createRequire` usage if importing from
|
||||
// `css-tree` Node module directly. This allows the production build of Svelte to work correctly.
|
||||
import { fork } from '../../../../../node_modules/css-tree/dist/csstree.esm.js';
|
||||
|
||||
import * as Comparison from './node/comparison';
|
||||
import * as ContainerFeature from './node/container_feature';
|
||||
import * as ContainerFeatureRange from './node/container_feature_range';
|
||||
import * as ContainerFeatureStyle from './node/container_feature_style';
|
||||
import * as ContainerQuery from './node/container_query';
|
||||
import * as QueryCSSFunction from './node/query_css_function';
|
||||
|
||||
/**
|
||||
* Extends `css-tree` for container query support by forking and adding new nodes and at-rule support for `@container`.
|
||||
*
|
||||
* The new nodes are located in `./node`.
|
||||
*/
|
||||
const cqSyntax = fork({
|
||||
atrule: { // extend or override at-rule dictionary
|
||||
container: {
|
||||
parse: {
|
||||
prelude() {
|
||||
return this.createSingleNodeList(
|
||||
this.ContainerQuery()
|
||||
);
|
||||
},
|
||||
block(isStyleBlock = false) {
|
||||
return this.Block(isStyleBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
node: { // extend node types
|
||||
Comparison,
|
||||
ContainerFeature,
|
||||
ContainerFeatureRange,
|
||||
ContainerFeatureStyle,
|
||||
ContainerQuery,
|
||||
QueryCSSFunction
|
||||
}
|
||||
});
|
||||
|
||||
export const parse = cqSyntax.parse;
|
@ -0,0 +1,48 @@
|
||||
// @ts-nocheck
|
||||
import { Delim } from 'css-tree/tokenizer';
|
||||
|
||||
export const name = 'Comparison';
|
||||
export const structure = {
|
||||
value: String
|
||||
};
|
||||
|
||||
export function parse() {
|
||||
const start = this.tokenStart;
|
||||
|
||||
const char1 = this.consume(Delim);
|
||||
|
||||
// The first character in the comparison operator must match '<', '=', or '>'.
|
||||
if (char1 !== '<' && char1 !== '>' && char1 !== '=') {
|
||||
this.error('Malformed comparison operator');
|
||||
}
|
||||
|
||||
let char2;
|
||||
|
||||
if (this.tokenType === Delim) {
|
||||
char2 = this.consume(Delim);
|
||||
|
||||
// The second character in the comparison operator must match '='.
|
||||
if (char2 !== '=') {
|
||||
this.error('Malformed comparison operator');
|
||||
}
|
||||
}
|
||||
|
||||
// If the next token is also 'Delim' then it is malformed.
|
||||
if (this.tokenType === Delim) {
|
||||
this.error('Malformed comparison operator');
|
||||
}
|
||||
|
||||
const value = char2 ? `${char1}${char2}` : char1;
|
||||
|
||||
return {
|
||||
type: 'Comparison',
|
||||
loc: this.getLocation(start, this.tokenStart),
|
||||
value
|
||||
};
|
||||
}
|
||||
|
||||
export function generate(node) {
|
||||
for (let index = 0; index < node.value.length; index++) {
|
||||
this.token(Delim, node.value.charAt(index));
|
||||
}
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
// @ts-nocheck
|
||||
import {
|
||||
Ident,
|
||||
Number,
|
||||
Dimension,
|
||||
Function,
|
||||
LeftParenthesis,
|
||||
RightParenthesis,
|
||||
Colon,
|
||||
Delim
|
||||
} from 'css-tree/tokenizer';
|
||||
|
||||
export const name = 'ContainerFeature';
|
||||
export const structure = {
|
||||
name: String,
|
||||
value: ['Identifier', 'Number', 'Dimension', 'QueryCSSFunction', 'Ratio', null]
|
||||
};
|
||||
|
||||
export function parse() {
|
||||
const start = this.tokenStart;
|
||||
let value = null;
|
||||
|
||||
this.eat(LeftParenthesis);
|
||||
this.skipSC();
|
||||
|
||||
const name = this.consume(Ident);
|
||||
this.skipSC();
|
||||
|
||||
if (this.tokenType !== RightParenthesis) {
|
||||
this.eat(Colon);
|
||||
this.skipSC();
|
||||
|
||||
switch (this.tokenType) {
|
||||
case Number:
|
||||
if (this.lookupNonWSType(1) === Delim) {
|
||||
value = this.Ratio();
|
||||
} else {
|
||||
value = this.Number();
|
||||
}
|
||||
break;
|
||||
|
||||
case Dimension:
|
||||
value = this.Dimension();
|
||||
break;
|
||||
|
||||
case Function:
|
||||
value = this.QueryCSSFunction();
|
||||
break;
|
||||
|
||||
case Ident:
|
||||
value = this.Identifier();
|
||||
break;
|
||||
|
||||
default:
|
||||
this.error('Number, dimension, ratio, function, or identifier is expected');
|
||||
break;
|
||||
}
|
||||
|
||||
this.skipSC();
|
||||
}
|
||||
|
||||
this.eat(RightParenthesis);
|
||||
|
||||
return {
|
||||
type: 'ContainerFeature',
|
||||
loc: this.getLocation(start, this.tokenStart),
|
||||
name,
|
||||
value
|
||||
};
|
||||
}
|
||||
|
||||
export function generate(node) {
|
||||
this.token(LeftParenthesis, '(');
|
||||
this.token(Ident, node.name);
|
||||
|
||||
if (node.value !== null) {
|
||||
this.token(Colon, ':');
|
||||
this.node(node.value);
|
||||
}
|
||||
|
||||
this.token(RightParenthesis, ')');
|
||||
}
|
@ -0,0 +1,86 @@
|
||||
// @ts-nocheck
|
||||
import {
|
||||
Ident,
|
||||
Number,
|
||||
Delim,
|
||||
Dimension,
|
||||
Function,
|
||||
LeftParenthesis,
|
||||
RightParenthesis,
|
||||
WhiteSpace
|
||||
} from 'css-tree/tokenizer';
|
||||
|
||||
export const name = 'ContainerFeatureRange';
|
||||
export const structure = {
|
||||
name: String,
|
||||
value: ['Identifier', 'Number', 'Comparison', 'Dimension', 'QueryCSSFunction', 'Ratio', null]
|
||||
};
|
||||
|
||||
function lookup_non_WS_type_and_value(offset, type, referenceStr) {
|
||||
let current_type;
|
||||
|
||||
do {
|
||||
current_type = this.lookupType(offset++);
|
||||
if (current_type !== WhiteSpace) {
|
||||
break;
|
||||
}
|
||||
} while (current_type !== 0); // NULL -> 0
|
||||
|
||||
return current_type === type ? this.lookupValue(offset - 1, referenceStr) : false;
|
||||
}
|
||||
|
||||
export function parse() {
|
||||
const children = this.createList();
|
||||
let child = null;
|
||||
|
||||
this.eat(LeftParenthesis);
|
||||
this.skipSC();
|
||||
|
||||
while (!this.eof && this.tokenType !== RightParenthesis) {
|
||||
switch (this.tokenType) {
|
||||
case Number:
|
||||
if (lookup_non_WS_type_and_value.call(this, 1, Delim, '/')) {
|
||||
child = this.Ratio();
|
||||
} else {
|
||||
child = this.Number();
|
||||
}
|
||||
break;
|
||||
|
||||
case Delim:
|
||||
child = this.Comparison();
|
||||
break;
|
||||
|
||||
case Dimension:
|
||||
child = this.Dimension();
|
||||
break;
|
||||
|
||||
case Function:
|
||||
child = this.QueryCSSFunction();
|
||||
break;
|
||||
|
||||
case Ident:
|
||||
child = this.Identifier();
|
||||
break;
|
||||
|
||||
default:
|
||||
this.error('Number, dimension, comparison, ratio, function, or identifier is expected');
|
||||
break;
|
||||
}
|
||||
|
||||
children.push(child);
|
||||
|
||||
this.skipSC();
|
||||
}
|
||||
|
||||
this.eat(RightParenthesis);
|
||||
|
||||
return {
|
||||
type: 'ContainerFeatureRange',
|
||||
loc: this.getLocationFromList(children),
|
||||
children
|
||||
};
|
||||
}
|
||||
|
||||
export function generate(node) {
|
||||
this.children(node);
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
// @ts-nocheck
|
||||
import {
|
||||
Function,
|
||||
Ident,
|
||||
Number,
|
||||
Dimension,
|
||||
RightParenthesis,
|
||||
Colon,
|
||||
Delim
|
||||
} from 'css-tree/tokenizer';
|
||||
|
||||
export const name = 'ContainerFeatureStyle';
|
||||
export const structure = {
|
||||
name: String,
|
||||
value: ['Function', 'Identifier', 'Number', 'Dimension', 'QueryCSSFunction', 'Ratio', null]
|
||||
};
|
||||
|
||||
export function parse() {
|
||||
const start = this.tokenStart;
|
||||
let value = null;
|
||||
|
||||
const function_name = this.consumeFunctionName();
|
||||
if (function_name !== 'style') {
|
||||
this.error('Unknown container style query identifier; "style" is expected');
|
||||
}
|
||||
|
||||
this.skipSC();
|
||||
|
||||
const name = this.consume(Ident);
|
||||
this.skipSC();
|
||||
|
||||
if (this.tokenType !== RightParenthesis) {
|
||||
this.eat(Colon);
|
||||
this.skipSC();
|
||||
|
||||
switch (this.tokenType) {
|
||||
case Number:
|
||||
if (this.lookupNonWSType(1) === Delim) {
|
||||
value = this.Ratio();
|
||||
} else {
|
||||
value = this.Number();
|
||||
}
|
||||
break;
|
||||
|
||||
case Dimension:
|
||||
value = this.Dimension();
|
||||
break;
|
||||
|
||||
case Function:
|
||||
value = this.QueryCSSFunction();
|
||||
break;
|
||||
|
||||
case Ident:
|
||||
value = this.Identifier();
|
||||
break;
|
||||
|
||||
default:
|
||||
this.error('Number, dimension, ratio, function or identifier is expected');
|
||||
break;
|
||||
}
|
||||
|
||||
this.skipSC();
|
||||
}
|
||||
|
||||
this.eat(RightParenthesis);
|
||||
|
||||
return {
|
||||
type: 'ContainerFeatureStyle',
|
||||
loc: this.getLocation(start, this.tokenStart),
|
||||
name,
|
||||
value
|
||||
};
|
||||
}
|
||||
|
||||
export function generate(node) {
|
||||
this.token(Function, 'style(');
|
||||
this.token(Ident, node.name);
|
||||
|
||||
if (node.value !== null) {
|
||||
this.token(Colon, ':');
|
||||
this.node(node.value);
|
||||
}
|
||||
|
||||
this.token(RightParenthesis, ')');
|
||||
}
|
@ -0,0 +1,130 @@
|
||||
// @ts-nocheck
|
||||
import {
|
||||
EOF,
|
||||
WhiteSpace,
|
||||
Comment,
|
||||
Delim,
|
||||
Function,
|
||||
Ident,
|
||||
LeftParenthesis,
|
||||
RightParenthesis,
|
||||
LeftCurlyBracket,
|
||||
Colon
|
||||
} from 'css-tree/tokenizer';
|
||||
|
||||
const CONTAINER_QUERY_KEYWORDS = new Set(['none', 'and', 'not', 'or']);
|
||||
|
||||
export const name = 'ContainerQuery';
|
||||
export const structure = {
|
||||
name: 'Identifier',
|
||||
children: [[
|
||||
'Identifier',
|
||||
'ContainerFeature',
|
||||
'ContainerFeatureRange',
|
||||
'ContainerFeatureStyle',
|
||||
'WhiteSpace'
|
||||
]]
|
||||
};
|
||||
|
||||
/**
|
||||
* Looks ahead to determine if query feature is a range query. This involves locating at least one delimiter and no
|
||||
* colon tokens.
|
||||
*
|
||||
* @returns {boolean} Is potential range query.
|
||||
*/
|
||||
function lookahead_is_range() {
|
||||
let type;
|
||||
let offset = 0;
|
||||
|
||||
let count = 0;
|
||||
let delim_found = false;
|
||||
let no_colon = true;
|
||||
|
||||
// A range query has maximum 5 tokens when formatted as 'mf-range' /
|
||||
// '<mf-value> <mf-lt> <mf-name> <mf-lt> <mf-value>'. So only look ahead maximum of 6 non-whitespace tokens.
|
||||
do {
|
||||
type = this.lookupNonWSType(offset++);
|
||||
if (type !== WhiteSpace) {
|
||||
count++;
|
||||
}
|
||||
if (type === Delim) {
|
||||
delim_found = true;
|
||||
}
|
||||
if (type === Colon) {
|
||||
no_colon = false;
|
||||
}
|
||||
if (type === LeftCurlyBracket || type === RightParenthesis) {
|
||||
break;
|
||||
}
|
||||
} while (type !== EOF && count <= 6);
|
||||
|
||||
return delim_found && no_colon;
|
||||
}
|
||||
|
||||
export function parse() {
|
||||
const start = this.tokenStart;
|
||||
const children = this.createList();
|
||||
let child = null;
|
||||
let name = null;
|
||||
|
||||
// Parse potential container name.
|
||||
if (this.tokenType === Ident) {
|
||||
const container_name = this.substring(this.tokenStart, this.tokenEnd);
|
||||
|
||||
// Container name doesn't match a query keyword, so assign it as container name.
|
||||
if (!CONTAINER_QUERY_KEYWORDS.has(container_name.toLowerCase())) {
|
||||
name = container_name;
|
||||
this.eatIdent(container_name);
|
||||
}
|
||||
}
|
||||
|
||||
this.skipSC();
|
||||
|
||||
scan:
|
||||
while (!this.eof) {
|
||||
switch (this.tokenType) {
|
||||
case Comment:
|
||||
case WhiteSpace:
|
||||
this.next();
|
||||
continue;
|
||||
|
||||
case Ident:
|
||||
child = this.Identifier();
|
||||
break;
|
||||
|
||||
case Function:
|
||||
child = this.ContainerFeatureStyle();
|
||||
break;
|
||||
|
||||
case LeftParenthesis:
|
||||
// Lookahead to determine if range feature.
|
||||
child = lookahead_is_range.call(this) ? this.ContainerFeatureRange() : this.ContainerFeature();
|
||||
break;
|
||||
|
||||
default:
|
||||
break scan;
|
||||
}
|
||||
|
||||
children.push(child);
|
||||
}
|
||||
|
||||
if (child === null) {
|
||||
this.error('Identifier or parenthesis is expected');
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'ContainerQuery',
|
||||
loc: this.getLocation(start, this.tokenStart - 1),
|
||||
name,
|
||||
children
|
||||
};
|
||||
}
|
||||
|
||||
export function generate(node) {
|
||||
if (typeof node.name === 'string') {
|
||||
this.token(Ident, node.name);
|
||||
}
|
||||
|
||||
this.children(node);
|
||||
}
|
||||
|
@ -0,0 +1,41 @@
|
||||
// @ts-nocheck
|
||||
import {
|
||||
RightParenthesis
|
||||
} from 'css-tree/tokenizer';
|
||||
|
||||
const QUERY_CSS_FUNCTIONS = new Set(['calc', 'clamp', 'min', 'max']);
|
||||
|
||||
export const name = 'QueryCSSFunction';
|
||||
export const structure = {
|
||||
name: String,
|
||||
expression: String
|
||||
};
|
||||
|
||||
export function parse() {
|
||||
const start = this.tokenStart;
|
||||
|
||||
const name = this.consumeFunctionName();
|
||||
|
||||
if (!QUERY_CSS_FUNCTIONS.has(name)) {
|
||||
this.error('Unknown query single value function; expected: "calc", "clamp", "max", min"');
|
||||
}
|
||||
|
||||
const body = this.Raw(this.tokenIndex, null, false);
|
||||
|
||||
this.eat(RightParenthesis);
|
||||
|
||||
return {
|
||||
type: 'QueryCSSFunction',
|
||||
loc: this.getLocation(start, this.tokenStart),
|
||||
name,
|
||||
expression: body.value
|
||||
};
|
||||
}
|
||||
|
||||
export function generate(node) {
|
||||
this.token(Function, `${node.name}(`);
|
||||
|
||||
this.node(node.expression);
|
||||
|
||||
this.token(RightParenthesis, ')');
|
||||
}
|
@ -0,0 +1 @@
|
||||
div.svelte-xyz{container:test-container / inline-size}@container (min-width: 400px){div.svelte-xyz{color:red}}@container test-container (min-width: 410px){div.svelte-xyz{color:green}}@container test-container (width < 400px){div.svelte-xyz{color:blue}}@container test-container (0 <= width < 300px){div.svelte-xyz{color:purple}}@container not (width < 400px){div.svelte-xyz{color:pink}}@container (width > 400px) and (height > 400px){div.svelte-xyz{color:lightgreen}}@container (width > 400px) or (height > 400px){div.svelte-xyz{color:lightblue}}@container (width > 400px) and (width > 800px) or (orientation: portrait){div.svelte-xyz{color:salmon}}@container style(color: blue){div.svelte-xyz{color:tan}}@container test-container (min-width: calc(400px + 1px)){div.svelte-xyz{color:green}}@container test-container (width < clamp(200px, 40%, 400px)){div.svelte-xyz{color:blue}}@container test-container (calc(400px + 1px) <= width < calc(500px + 1px)){div.svelte-xyz{color:purple}}@container style(--var: calc(400px + 1px)){div.svelte-xyz{color:sandybrown}}
|
@ -0,0 +1,87 @@
|
||||
<div>container query</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
container: test-container / inline-size;
|
||||
}
|
||||
|
||||
/* Most common container query statements. */
|
||||
|
||||
@container (min-width: 400px) {
|
||||
div {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
@container test-container (min-width: 410px) {
|
||||
div {
|
||||
color: green;
|
||||
}
|
||||
}
|
||||
|
||||
@container test-container (width < 400px) {
|
||||
div {
|
||||
color: blue;
|
||||
}
|
||||
}
|
||||
|
||||
@container test-container (0 <= width < 300px) {
|
||||
div {
|
||||
color: purple;
|
||||
}
|
||||
}
|
||||
|
||||
@container not (width < 400px) {
|
||||
div {
|
||||
color: pink;
|
||||
}
|
||||
}
|
||||
|
||||
@container (width > 400px) and (height > 400px) {
|
||||
div {
|
||||
color: lightgreen;
|
||||
}
|
||||
}
|
||||
|
||||
@container (width > 400px) or (height > 400px) {
|
||||
div {
|
||||
color: lightblue;
|
||||
}
|
||||
}
|
||||
|
||||
@container (width > 400px) and (width > 800px) or (orientation: portrait) {
|
||||
div {
|
||||
color: salmon;
|
||||
}
|
||||
}
|
||||
|
||||
@container style(color: blue) {
|
||||
div {
|
||||
color: tan;
|
||||
}
|
||||
}
|
||||
|
||||
@container test-container (min-width: calc(400px + 1px)) {
|
||||
div {
|
||||
color: green;
|
||||
}
|
||||
}
|
||||
|
||||
@container test-container (width < clamp(200px, 40%, 400px)) {
|
||||
div {
|
||||
color: blue;
|
||||
}
|
||||
}
|
||||
|
||||
@container test-container (calc(400px + 1px) <= width < calc(500px + 1px)) {
|
||||
div {
|
||||
color: purple;
|
||||
}
|
||||
}
|
||||
|
||||
@container style(--var: calc(400px + 1px)) {
|
||||
div {
|
||||
color: sandybrown;
|
||||
}
|
||||
}
|
||||
</style>
|
Loading…
Reference in new issue