diff --git a/.changeset/rare-numbers-pull.md b/.changeset/rare-numbers-pull.md new file mode 100644 index 000000000..25fc34a8e --- /dev/null +++ b/.changeset/rare-numbers-pull.md @@ -0,0 +1,5 @@ +--- +'@vanilla-extract/babel-plugin': minor +--- + +Add support for the new `createContainer` API diff --git a/.changeset/real-cherries-give.md b/.changeset/real-cherries-give.md new file mode 100644 index 000000000..fff3bf8a5 --- /dev/null +++ b/.changeset/real-cherries-give.md @@ -0,0 +1,28 @@ +--- +'@vanilla-extract/css': minor +--- + +Add `createContainer` API + +`createContainer` creates a single scoped container name for use with CSS Container Queries. This avoids potential naming collisions with other containers. + +```ts +import { + style, + createContainer +} from '@vanilla-extract/css'; + +export const sidebarContainer = createContainer(); + +export const sidebar = style({ + containerName: sidebarContainer +}); + +export const navigation = style({ + '@container': { + [`${sidebarContainer} (min-width: 400px)`]: { + display: 'flex' + } + } +}); +``` \ No newline at end of file diff --git a/.changeset/tiny-shirts-poke.md b/.changeset/tiny-shirts-poke.md new file mode 100644 index 000000000..f64f846ec --- /dev/null +++ b/.changeset/tiny-shirts-poke.md @@ -0,0 +1,17 @@ +--- +'@vanilla-extract/css': minor +--- + +Add support for container queries via the new `@container` key. + +```ts +import { style } from '@vanilla-extract/css'; + +export const myStyle = style({ + '@container': { + '(min-width: 400px)': { + display: 'flex' + } + } +}); +``` diff --git a/fixtures/low-level/src/index.ts b/fixtures/low-level/src/index.ts index d31064023..05297f2ea 100644 --- a/fixtures/low-level/src/index.ts +++ b/fixtures/low-level/src/index.ts @@ -1,8 +1,10 @@ -import { block } from './styles.css'; +import { block, container } from './styles.css'; import testNodes from '../test-nodes.json'; document.body.innerHTML = ` +
I'm a block
+
`; diff --git a/fixtures/low-level/src/styles.css.ts b/fixtures/low-level/src/styles.css.ts index 957c3b322..42d743809 100644 --- a/fixtures/low-level/src/styles.css.ts +++ b/fixtures/low-level/src/styles.css.ts @@ -1,12 +1,28 @@ -import { style, createVar } from '@vanilla-extract/css'; +import { style, createVar, createContainer } from '@vanilla-extract/css'; const color = createVar(); +const myContainer = createContainer('my-container'); + +export const container = style({ + containerType: 'size', + containerName: myContainer, + width: 500, +}); + export const block = style({ vars: { [color]: 'blue', }, backgroundColor: color, - color: 'white', padding: 20, + '@media': { + 'screen and (min-width: 200px)': { + '@container': { + [`${myContainer} (min-width: 400px)`]: { + color: 'white', + }, + }, + }, + }, }); diff --git a/packages/babel-plugin/src/index.test.ts b/packages/babel-plugin/src/index.test.ts index 85655a084..4d84552bb 100644 --- a/packages/babel-plugin/src/index.test.ts +++ b/packages/babel-plugin/src/index.test.ts @@ -269,6 +269,25 @@ describe('babel plugin', () => { `); }); + it('should handle createContainer assigned to const', () => { + const source = ` + import { createContainer } from '@vanilla-extract/css'; + + const myContainer = createContainer(); + `; + + expect(transform(source)).toMatchInlineSnapshot(` + "import * as __vanilla_filescope__ from '@vanilla-extract/css/fileScope'; + + __vanilla_filescope__.setFileScope(\\"src/dir/mockFilename.css.ts\\", \\"@vanilla-extract/babel-plugin\\"); + + import { createContainer } from '@vanilla-extract/css'; + const myContainer = createContainer(\\"myContainer\\"); + + __vanilla_filescope__.endFileScope();" + `); + }); + it('should handle fontFace assigned to const', () => { const source = ` import { fontFace } from '@vanilla-extract/css'; diff --git a/packages/babel-plugin/src/index.ts b/packages/babel-plugin/src/index.ts index b2452c81c..3e2a19d2c 100644 --- a/packages/babel-plugin/src/index.ts +++ b/packages/babel-plugin/src/index.ts @@ -43,6 +43,9 @@ const debuggableFunctionConfig = { recipe: { maxParams: 2, }, + createContainer: { + maxParams: 1, + }, }; const styleFunctions = [ diff --git a/packages/css/src/container.ts b/packages/css/src/container.ts new file mode 100644 index 000000000..7760f987c --- /dev/null +++ b/packages/css/src/container.ts @@ -0,0 +1,6 @@ +import { generateIdentifier } from './identifier'; + +// createContainer is used for local scoping of CSS containers +// For now it is mostly just an alias of generateIdentifier +export const createContainer = (debugId?: string) => + generateIdentifier(debugId); diff --git a/packages/css/src/index.ts b/packages/css/src/index.ts index 2cc5e58c8..57ae91eab 100644 --- a/packages/css/src/index.ts +++ b/packages/css/src/index.ts @@ -12,3 +12,4 @@ export * from './identifier'; export * from './theme'; export * from './style'; export * from './vars'; +export { createContainer } from './container'; diff --git a/packages/css/src/transformCss.test.ts b/packages/css/src/transformCss.test.ts index 5adc2ec99..35ac38bbf 100644 --- a/packages/css/src/transformCss.test.ts +++ b/packages/css/src/transformCss.test.ts @@ -961,7 +961,41 @@ describe('transformCss', () => { `); }); - it('should handle nested @supports and @media queries', () => { + it('should handle @container queries', () => { + expect( + transformCss({ + composedClassLists: [], + localClassNames: ['testClass'], + cssObjs: [ + { + type: 'local', + selector: 'testClass', + rule: { + display: 'flex', + containerName: 'sidebar', + '@container': { + 'sidebar (min-width: 700px)': { + display: 'grid', + }, + }, + }, + }, + ], + }).join('\n'), + ).toMatchInlineSnapshot(` + ".testClass { + display: flex; + container-name: sidebar; + } + @container sidebar (min-width: 700px) { + .testClass { + display: grid; + } + }" + `); + }); + + it('should handle nested @supports, @media and @container queries', () => { expect( transformCss({ composedClassLists: [], @@ -978,16 +1012,27 @@ describe('transformCss', () => { '@media': { 'screen and (min-width: 700px)': { display: 'grid', + '@container': { + 'sidebar (min-width: 700px)': { + display: 'grid', + }, + }, }, }, }, }, + '@media': { 'screen and (min-width: 700px)': { color: 'green', '@supports': { '(display: grid)': { borderColor: 'blue', + '@container': { + 'sidebar (min-width: 700px)': { + display: 'grid', + }, + }, }, }, }, @@ -1008,6 +1053,11 @@ describe('transformCss', () => { .testClass { border-color: blue; } + @container sidebar (min-width: 700px) { + .testClass { + display: grid; + } + } } } @supports (display: grid) { @@ -1018,12 +1068,17 @@ describe('transformCss', () => { .testClass { display: grid; } + @container sidebar (min-width: 700px) { + .testClass { + display: grid; + } + } } }" `); }); - it('should merge nested @supports and @media queries', () => { + it('should merge nested @supports, @media and @container queries', () => { expect( transformCss({ composedClassLists: [], @@ -1038,12 +1093,18 @@ describe('transformCss', () => { '@supports': { '(display: grid)': { borderColor: 'blue', + '@container': { + 'sidebar (min-width: 700px)': { + display: 'grid', + }, + }, }, }, }, }, }, }, + { type: 'local', selector: 'otherClass', @@ -1053,6 +1114,11 @@ describe('transformCss', () => { '@supports': { '(display: grid)': { backgroundColor: 'yellow', + '@container': { + 'sidebar (min-width: 700px)': { + display: 'grid', + }, + }, }, }, }, @@ -1070,6 +1136,14 @@ describe('transformCss', () => { .otherClass { background-color: yellow; } + @container sidebar (min-width: 700px) { + .testClass { + display: grid; + } + .otherClass { + display: grid; + } + } } }" `); diff --git a/packages/css/src/transformCss.ts b/packages/css/src/transformCss.ts index 4deae29fb..77557c9dd 100644 --- a/packages/css/src/transformCss.ts +++ b/packages/css/src/transformCss.ts @@ -7,13 +7,12 @@ import type { CSSStyleBlock, CSSKeyframesBlock, CSSPropertiesWithVars, - FeatureQueries, - MediaQueries, StyleRule, StyleWithSelectors, GlobalFontFaceRule, CSSSelectorBlock, Composition, + WithQueries, } from './types'; import { markCompositionUsed } from './adapter'; import { forEach, omit, mapKeys } from './utils'; @@ -81,7 +80,13 @@ function dashify(str: string) { const DOUBLE_SPACE = ' '; -const specialKeys = [...simplePseudos, '@media', '@supports', 'selectors']; +const specialKeys = [ + ...simplePseudos, + '@media', + '@supports', + '@container', + 'selectors', +]; interface CSSRule { conditions?: Array; @@ -144,6 +149,7 @@ class Stylesheet { this.transformMedia(root, root.rule['@media']); this.transformSupports(root, root.rule['@supports']); + this.transformContainer(root, root.rule['@container']); this.transformSimplePseudos(root, root.rule); this.transformSelectors(root, root.rule); @@ -318,9 +324,7 @@ class Stylesheet { transformMedia( root: CSSStyleBlock | CSSSelectorBlock, - rules: - | MediaQueries> - | undefined, + rules: WithQueries['@media'], parentConditions: Array = [], ) { if (rules) { @@ -350,15 +354,49 @@ class Stylesheet { } this.transformSupports(root, mediaRule!['@supports'], conditions); + this.transformContainer(root, mediaRule!['@container'], conditions); + }); + } + } + + transformContainer( + root: CSSStyleBlock | CSSSelectorBlock, + rules: WithQueries['@container'], + parentConditions: Array = [], + ) { + if (rules) { + this.currConditionalRuleset?.addConditionPrecedence( + parentConditions, + Object.keys(rules).map((query) => `@container ${query}`), + ); + + forEach(rules, (containerRule, query) => { + const containerQuery = `@container ${query}`; + + const conditions = [...parentConditions, containerQuery]; + + this.addConditionalRule( + { + selector: root.selector, + rule: omit(containerRule, specialKeys), + }, + conditions, + ); + + if (root.type === 'local') { + this.transformSimplePseudos(root, containerRule!, conditions); + this.transformSelectors(root, containerRule!, conditions); + } + + this.transformSupports(root, containerRule!['@supports'], conditions); + this.transformMedia(root, containerRule!['@media'], conditions); }); } } transformSupports( root: CSSStyleBlock | CSSSelectorBlock, - rules: - | FeatureQueries> - | undefined, + rules: WithQueries['@supports'], parentConditions: Array = [], ) { if (rules) { @@ -383,6 +421,7 @@ class Stylesheet { this.transformSelectors(root, supportsRule!, conditions); } this.transformMedia(root, supportsRule!['@media'], conditions); + this.transformContainer(root, supportsRule!['@container'], conditions); }); } } diff --git a/packages/css/src/types.ts b/packages/css/src/types.ts index c366db779..5f0038483 100644 --- a/packages/css/src/types.ts +++ b/packages/css/src/types.ts @@ -1,15 +1,25 @@ import type { MapLeafNodes, CSSVarFunction } from '@vanilla-extract/private'; -import type { PropertiesFallback, AtRule, Properties } from 'csstype'; +import type { Properties, AtRule } from 'csstype'; import type { SimplePseudos } from './simplePseudos'; -type CSSTypeProperties = PropertiesFallback; +// csstype is yet to ship container property types as they are not in +// the output MDN spec files yet. Remove this once that's done. +// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Container_Queries +interface ContainerProperties { + container?: string; + containerType?: 'size' | 'inline-size' | (string & {}); + containerName?: string; +} + +type CSSTypeProperties = Properties & + ContainerProperties; export type CSSProperties = { [Property in keyof CSSTypeProperties]: | CSSTypeProperties[Property] | CSSVarFunction - | Array; + | Array; }; export interface CSSKeyframes { @@ -28,14 +38,6 @@ type PseudoProperties = { type CSSPropertiesAndPseudos = CSSPropertiesWithVars & PseudoProperties; -interface SelectorMap { - [selector: string]: CSSPropertiesWithVars & - MediaQueries< - CSSPropertiesWithVars & FeatureQueries - > & - FeatureQueries>; -} - export interface MediaQueries { '@media'?: { [query: string]: StyleType; @@ -48,17 +50,41 @@ export interface FeatureQueries { }; } +export interface ContainerQueries { + '@container'?: { + [query: string]: StyleType; + }; +} + +export type WithQueries = MediaQueries< + StyleType & + FeatureQueries> & + ContainerQueries> +> & + FeatureQueries< + StyleType & + MediaQueries> & + ContainerQueries> + > & + ContainerQueries< + StyleType & + MediaQueries> & + FeatureQueries> + >; + +interface SelectorMap { + [selector: string]: CSSPropertiesWithVars & + WithQueries; +} + export interface StyleWithSelectors extends CSSPropertiesAndPseudos { selectors?: SelectorMap; } -export type StyleRule = StyleWithSelectors & - MediaQueries> & - FeatureQueries>; +export type StyleRule = StyleWithSelectors & WithQueries; export type GlobalStyleRule = CSSPropertiesWithVars & - MediaQueries> & - FeatureQueries>; + WithQueries; export type GlobalFontFaceRule = Omit & Required>; diff --git a/site/contents.js b/site/contents.js index d316a09ba..98ff9dffc 100644 --- a/site/contents.js +++ b/site/contents.js @@ -23,6 +23,7 @@ const contents = [ 'assign-vars', 'font-face', 'keyframes', + 'create-container', ], }, { diff --git a/site/docs/api/create-container.md b/site/docs/api/create-container.md new file mode 100644 index 000000000..b302d7475 --- /dev/null +++ b/site/docs/api/create-container.md @@ -0,0 +1,43 @@ +--- +title: createContainer +parent: api +--- + +# createContainer + +Creates a single scoped container name for use with [CSS Container Queries]. This avoids potential naming collisions with other containers. + +> 🚧  Ensure your target browsers [support container queries]. +> Vanilla-extract supports the [container query syntax][css container queries] but does not polyfill the feature in unsupported browsers. + +```ts compiled +// sidebar.css.ts +import { + style, + createContainer +} from '@vanilla-extract/css'; + +export const sidebarContainer = createContainer(); + +export const sidebar = style({ + containerName: sidebarContainer +}); + +// navigation.css.ts +import { + style, + createContainer +} from '@vanilla-extract/css'; +import { sidebarContainer } from './sidebar.css.ts'; + +export const navigation = style({ + '@container': { + [`${sidebarContainer} (min-width: 400px)`]: { + display: 'flex' + } + } +}); +``` + +[css container queries]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Container_Queries +[support container queries]: https://caniuse.com/css-container-queries diff --git a/site/docs/overview/styling.md b/site/docs/overview/styling.md index 15b840f0b..3e0709516 100644 --- a/site/docs/overview/styling.md +++ b/site/docs/overview/styling.md @@ -114,7 +114,7 @@ const myStyle = style({ When processing your code into CSS, vanilla-extract will always render your media queries **at the end of the file**. This means styles inside the `@media` key will always have higher precedence than other styles due to CSS rule order precedence. -> 🧠  When it's safe to do so, vanilla-extract will merge your `@media` (and `@supports`) condition blocks together to create the smallest possible CSS output. +> 🧠  When it's safe to do so, vanilla-extract will merge your `@media`, `@supports`, and `@container` condition blocks together to create the smallest possible CSS output. ## Selectors @@ -237,6 +237,46 @@ globalStyle(`${parent} a[href]`, { }); ``` +## Container Queries + +Container queries work the same as [media queries] and are nested inside the `@container` key. + +> 🚧  Ensure your target browsers [support container queries]. Vanilla-extract supports the [container query syntax] but does not polyfill the feature in unsupported browsers. + +```ts compiled +// styles.css.ts +import { style } from '@vanilla-extract/css'; + +const myStyle = style({ + '@container': { + '(min-width: 768px)': { + padding: 10 + } + } +}); +``` + +You can also create scoped containers using [createContainer]. + +```ts compiled +// styles.css.ts +import { + style, + createContainer +} from '@vanilla-extract/css'; + +const sidebar = createContainer(); + +const myStyle = style({ + containerName: sidebar, + '@container': { + [`${sidebar} (min-width: 768px)`]: { + padding: 10 + } + } +}); +``` + ## Supports Queries Supports queries work the same as [Media queries] and are nested inside the `@supports` key. @@ -275,7 +315,10 @@ export const myStyle = style({ [csstype]: https://github.com/frenic/csstype [unitless properties]: https://github.com/seek-oss/vanilla-extract/blob/6068246343ceb58a04006f4ce9d9ff7ecc7a6c09/packages/css/src/transformCss.ts#L25 [createvar]: /documentation/api/create-var/ +[createcontainer]: /documentation/api/create-container/ [css properties]: #css-properties [css variables]: #css-variables [globalstyle]: /documentation/global-api/global-style [media queries]: #media-queries +[support container queries]: https://caniuse.com/css-container-queries +[container query syntax]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Container_Queries diff --git a/site/src/HomePage/HomePage.tsx b/site/src/HomePage/HomePage.tsx index 9ac710af8..b79a7ff29 100644 --- a/site/src/HomePage/HomePage.tsx +++ b/site/src/HomePage/HomePage.tsx @@ -316,8 +316,8 @@ export const HomePage = () => { Write maintainable CSS at scale without sacrificing platform features. Variables, selectors, pseudo‑classes, - media/feature queries, keyframes, font‑face rules and - global styles are all supported. + media/feature/container queries, keyframes, font‑face + and global styles are all supported. diff --git a/tests/stylesheets/__snapshots__/low-level.test.ts.snap b/tests/stylesheets/__snapshots__/low-level.test.ts.snap new file mode 100644 index 000000000..cc3c2ecad --- /dev/null +++ b/tests/stylesheets/__snapshots__/low-level.test.ts.snap @@ -0,0 +1,64 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`low-level - esbuild should create valid stylesheet 1`] = ` +"._1amv5mo2 { + container-type: size; + container-name: _1amv5mo1; + width: 500px; +} +._1amv5mo3 { + --_1amv5mo0: blue; + background-color: var(--_1amv5mo0); + padding: 20px; +} +@media screen and (min-width: 200px) { + @container _1amv5mo1 (min-width: 400px) { + ._1amv5mo3 { + color: #fff; + } + } +} +" +`; + +exports[`low-level - mini-css-extract should create valid stylesheet 1`] = ` +".cj5d032 { + container-type: size; + container-name: cj5d031; + width: 500px; +} +.cj5d033 { + --cj5d030: blue; + background-color: var(--cj5d030); + padding: 20px; +} +@media screen and (min-width: 200px) { + @container cj5d031 (min-width: 400px) { + .cj5d033 { + color: #fff; + } + } +} +" +`; + +exports[`low-level - vite should create valid stylesheet 1`] = ` +"._1amv5mo2 { + container-type: size; + container-name: _1amv5mo1; + width: 500px; +} +._1amv5mo3 { + --_1amv5mo0: blue; + background-color: var(--_1amv5mo0); + padding: 20px; +} +@media screen and (min-width: 200px) { + @container _1amv5mo1 (min-width: 400px) { + ._1amv5mo3 { + color: #fff; + } + } +} +" +`; diff --git a/tests/stylesheets/low-level.test.ts b/tests/stylesheets/low-level.test.ts new file mode 100644 index 000000000..4b2f88bff --- /dev/null +++ b/tests/stylesheets/low-level.test.ts @@ -0,0 +1,36 @@ +import { + getStylesheet, + startFixture, + TestServer, +} from '@vanilla-extract-private/test-helpers'; + +const workerIndex = parseInt(process.env.JEST_WORKER_ID ?? '', 10); +let testCounter = 0; + +const buildTypes = ['vite', 'esbuild', 'mini-css-extract'] as const; + +buildTypes.forEach((buildType) => { + describe(`low-level - ${buildType}`, () => { + let server: TestServer; + + beforeAll(async () => { + const portRange = 100 * workerIndex; + + server = await startFixture('low-level', { + type: buildType, + mode: 'production', + basePort: 12000 + portRange + testCounter++, + }); + }); + + test('should create valid stylesheet', async () => { + expect( + await getStylesheet(server.url, server.stylesheet), + ).toMatchSnapshot(); + }); + + afterAll(async () => { + await server.close(); + }); + }); +});