提交 ace5dd29 编写于 作者: E Evan Jacobs

feat: CSS variable injection, cssvar helper, media query theme pattern for cssvar

When using <ThemeProvider>, css variables synthesized from the given theme
will be automatically injected into the page. The developer can then decide to
consume the variables via the new "cssvar" helper.

In tandem with this change, I have introduced a special key to the theme object
definition ("MQ_THEME_KEY") that allows for media-query based adaptation of theme. Note: this only
applies to cssvar usage, useTheme and such will not adapt the retrieved value based
on MQ.

The primary use case for this functionality is easier SSR support for things like dark mode.
上级 4b55835f
......@@ -14,7 +14,8 @@
"dependencies": {
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-live": "^2.3.0"
"react-live": "^2.3.0",
"styled-components": "link:../styled-components"
},
"engines": {
"node": ">= 12"
......
import React from 'react';
import styled, { createGlobalStyle } from 'styled-components';
import styled, { createGlobalStyle, ThemeProvider } from 'styled-components';
import ButtonExample from '../src/Button.example';
import theme from '../src/theme';
const GlobalStyle = createGlobalStyle`
body {
......@@ -66,20 +67,22 @@ const Code = styled.span`
`;
const App = () => (
<Body>
<GlobalStyle />
<Heading>
<Title>
Interactive sandbox for <Code>styled-components</Code>
</Title>
<Subtitle>
Make changes to the files in <Code>./src</Code> and see them take effect in realtime!
</Subtitle>
</Heading>
<Content>
<ButtonExample />
</Content>
</Body>
<ThemeProvider theme={theme}>
<Body>
<GlobalStyle />
<Heading>
<Title>
Interactive sandbox for <Code>styled-components</Code>
</Title>
<Subtitle>
Make changes to the files in <Code>./src</Code> and see them take effect in realtime!
</Subtitle>
</Heading>
<Content>
<ButtonExample />
</Content>
</Body>
</ThemeProvider>
);
export default App;
import styled, { css } from 'styled-components';
import styled, { css, cssvar } from 'styled-components';
const Button = styled.button`
font-size: 16px;
......@@ -6,14 +6,14 @@ const Button = styled.button`
padding: 0.25em 1em;
margin: 1em 1em;
background: transparent;
color: palevioletred;
border: 2px solid palevioletred;
color: ${cssvar('buttonColor')};
border: 2px solid ${cssvar('buttonColor')};
cursor: pointer;
${props =>
props.primary &&
css`
background: palevioletred;
background: ${cssvar('buttonColor')};
color: white;
`};
`;
......
import { MQ_THEME_KEY } from 'styled-components';
export default {
[MQ_THEME_KEY]: {
'prefers-color-scheme: dark': {
buttonColor: 'blue',
},
'max-width: 1000px': {
buttonColor: 'green',
},
},
buttonColor: 'palevioletred',
};
......@@ -13,7 +13,12 @@ import StyleSheetManager, {
StyleSheetContext,
} from './models/StyleSheetManager';
/* Import components */
import ThemeProvider, { ThemeConsumer, ThemeContext } from './models/ThemeProvider';
import ThemeProvider, {
cssvar,
MQ_THEME_KEY,
ThemeConsumer,
ThemeContext,
} from './models/ThemeProvider';
import isStyledComponent from './utils/isStyledComponent';
declare const __SERVER__: boolean;
......@@ -64,8 +69,10 @@ export * from './secretInternals';
export {
createGlobalStyle,
css,
cssvar,
isStyledComponent,
keyframes,
MQ_THEME_KEY,
ServerStyleSheet,
StyleSheetConsumer,
StyleSheetContext,
......
......@@ -103,10 +103,13 @@ describe(`createGlobalStyle`, () => {
</ThemeProvider>
);
expect(getRenderedCSS()).toMatchInlineSnapshot(`
"div {
color: black;
}"
`);
"div {
color: black;
}
:root {
--sc-color: black;
}"
`);
});
it(`updates theme correctly`, () => {
......@@ -133,18 +136,24 @@ describe(`createGlobalStyle`, () => {
}
render(<App />);
expect(getRenderedCSS()).toMatchInlineSnapshot(`
"div {
color: grey;
}"
`);
"div {
color: grey;
}
:root {
--sc-color: grey;
}"
`);
// @ts-expect-error TS not detecting construction during render
update({ color: 'red' });
expect(getRenderedCSS()).toMatchInlineSnapshot(`
"div {
color: red;
}"
`);
"div {
color: red;
}
:root {
--sc-color: red;
}"
`);
});
it('should work in StrictMode without warnings', () => {
......
......@@ -2,7 +2,12 @@ import React, { useContext, useMemo } from 'react';
import styledError from '../utils/error';
import isFunction from '../utils/isFunction';
export const MQ_THEME_KEY = '_sc_media';
export type Theme = {
[MQ_THEME_KEY]?: {
[key: string]: any;
};
[key: string]: any;
};
......@@ -44,6 +49,71 @@ function mergeTheme(theme: ThemeArgument, outerTheme?: Theme): Theme {
return outerTheme ? { ...outerTheme, ...theme } : theme;
}
function synthesizeCSSVariables(obj: { [key: string]: any }, parentKey = ''): string {
let vars = '';
let needsToEmitMediaQueries = false;
if (!parentKey) {
vars += ':root{';
}
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
if (key === MQ_THEME_KEY) {
needsToEmitMediaQueries = true;
} else if (typeof obj[key] !== 'object') {
vars += `${normalizeKeyName(parentKey ? `${parentKey}.${key}` : key)}: ${obj[key]};`;
} else {
vars += synthesizeCSSVariables(obj[key], key);
}
}
}
if (!parentKey) vars += '}';
if (needsToEmitMediaQueries) {
for (const query in obj[MQ_THEME_KEY]) {
if (obj[MQ_THEME_KEY].hasOwnProperty(query)) {
vars += `@media (${query}){${synthesizeCSSVariables(obj[MQ_THEME_KEY][query])}}\n`;
}
}
}
return vars;
}
function normalizeKeyName(key: string): string {
return `--sc-${key
.toLowerCase()
.split('.')
.map(x => x.replace(/[^A-Z0-9]/gi, '-').replace(/-{2,}/g, '-'))
.join('-')}`;
}
// thank you https://www.reddit.com/r/typescript/comments/iywewf/comment/g6fh1m5/?utm_source=share&utm_medium=web2x&context=3
type PropsPath<T extends Theme> = {
[P in keyof T]: T[P] extends Theme
? `${string & P}` | `${string & P}.${PropsPath<T[P]>}`
: `${string & P}`;
}[T extends any[] ? number & keyof T : keyof T];
/**
* Given a theme key path (dot-delimited like JS), consume the appropriate emitted CSS variable
* taking into account any provided media query adjustments.
*
* e.g.
*
* ```
* styled.div`
* color: ${cssvar('color')};
* `
* ```
*/
export function cssvar<T = Theme>(key: PropsPath<T>): string {
// @ts-expect-error it's fine
return `var(${normalizeKeyName(String(key))})`;
}
/**
* Provide a theme to an entire react component tree via context
*/
......@@ -54,9 +124,23 @@ export default function ThemeProvider(props: Props): JSX.Element | null {
[props.theme, outerTheme]
);
/**
* Synthesize CSS variables from theme.
*/
const cssVars = useMemo(() => {
if (themeContext) {
return synthesizeCSSVariables(themeContext);
}
}, [themeContext]);
if (!props.children) {
return null;
}
return <ThemeContext.Provider value={themeContext}>{props.children}</ThemeContext.Provider>;
return (
<ThemeContext.Provider value={themeContext}>
{cssVars && <style dangerouslySetInnerHTML={{ __html: cssVars }} />}
{props.children}
</ThemeContext.Provider>
);
}
......@@ -2,8 +2,8 @@
import React from 'react';
import TestRenderer from 'react-test-renderer';
import withTheme from '../../hoc/withTheme';
import { resetStyled } from '../../test/utils';
import ThemeProvider from '../ThemeProvider';
import { expectCSSMatches, resetStyled } from '../../test/utils';
import ThemeProvider, { cssvar, MQ_THEME_KEY } from '../ThemeProvider';
let styled: ReturnType<typeof resetStyled>;
......@@ -125,4 +125,83 @@ describe('ThemeProvider', () => {
expect(wrapper.root.findByType(MyDiv).props.theme).toEqual(expected);
});
it('ThemeProvider emits the theme as CSS variables as well', () => {
const wrapper = TestRenderer.create(
<ThemeProvider
theme={{
[MQ_THEME_KEY]: { 'prefers-color-scheme: dark': { color: 'white' } },
color: 'red',
style: { background: 'blue' },
}}
>
<div />
</ThemeProvider>
);
expect(wrapper.toJSON()).toMatchInlineSnapshot(`
Array [
<style
dangerouslySetInnerHTML={
Object {
"__html": ":root{--sc-color: red;--sc-style-background: blue;}@media (prefers-color-scheme: dark){:root{--sc-color: white;}}
",
}
}
/>,
<div />,
]
`);
});
it('css variable synthesis emits media query variants properly', () => {
const wrapper = TestRenderer.create(
<ThemeProvider theme={{ color: 'red', style: { background: 'blue' } }}>
<div />
</ThemeProvider>
);
expect(wrapper.toJSON()).toMatchInlineSnapshot(`
Array [
<style
dangerouslySetInnerHTML={
Object {
"__html": ":root{--sc-color: red;--sc-style-background: blue;}",
}
}
/>,
<div />,
]
`);
});
it('cssvar helper accesses theme properties correctly', () => {
const theme = { color: 'red', style: { background: 'blue' } };
const MyDiv = styled.div`
color: ${cssvar<typeof theme>('color')};
`;
const wrapper = TestRenderer.create(
<ThemeProvider theme={theme}>
<MyDiv />
</ThemeProvider>
);
expectCSSMatches('.b { color:var(--sc-color); }');
expect(wrapper.toJSON()).toMatchInlineSnapshot(`
Array [
<style
dangerouslySetInnerHTML={
Object {
"__html": ":root{--sc-color: red;--sc-style-background: blue;}",
}
}
/>,
<div
className="sc-a b"
/>,
]
`);
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ThemeProvider should render its child 1`] = `
<p>
Child!
</p>
Array [
<style
dangerouslySetInnerHTML={
Object {
"__html": ":root{--sc-main: black;}",
}
}
/>,
<p>
Child!
</p>,
]
`;
......@@ -84,10 +84,19 @@ describe('attrs', () => {
</ThemeProvider>
).toJSON()
).toMatchInlineSnapshot(`
<button
className="sc-a"
data-color="red"
/>
Array [
<style
dangerouslySetInnerHTML={
Object {
"__html": ":root{--sc-color: red;}",
}
}
/>,
<button
className="sc-a"
data-color="red"
/>,
]
`);
});
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册