diff --git a/README.md b/README.md index 9c65298..7b71cd9 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,11 @@ CSS solution for light/dark/auto theme switcher for websites. by subset/sunrise (all operating systems now have theme switching schedule). [PostCSS] plugin to make switcher to force dark or light theme by copying styles -from media query to special class. +from media query or [light-dark()] to special class. [PostCSS]: https://github.com/postcss/postcss [FART]: https://css-tricks.com/flash-of-inaccurate-color-theme-fart/ +[light-dark()]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/light-dark ```css /* Input CSS */ @@ -28,6 +29,10 @@ from media query to special class. background: black } } + +section { + background: light-dark(white, black); +} ``` ```css @@ -47,6 +52,23 @@ html:where(.is-dark) { :where(html.is-dark) body { background: black } + +@media (prefers-color-scheme: dark) { + :where(html:not(.is-light)) section { + background: black; + } +} +:where(html.is-dark) section { + background: black; +} +@media (prefers-color-scheme: light) { + :where(html:not(.is-dark)) section { + background: white; + } +} +:where(html.is-light) section { + background: white; +} ``` By default (without classes on `html`), website will use browser dark/light diff --git a/index.js b/index.js index 568f4ae..7ab0699 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,7 @@ const PREFERS_COLOR_ONLY = /^\(\s*prefers-color-scheme\s*:\s*(dark|light)\s*\)$/ const PREFERS_COLOR = /\(\s*prefers-color-scheme\s*:\s*(dark|light)\s*\)/g +const LIGHT_DARK = /light-dark\(\s*(.+?)\s*,\s*(.+?)\s*\)/g +const STRING = /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/dg function escapeRegExp(string) { return string.replace(/[$()*+.?[\\\]^{|}-]/g, '\\$&') @@ -9,6 +11,38 @@ function replaceAll(string, find, replace) { return string.replace(new RegExp(escapeRegExp(find), 'g'), replace) } +function addColorSchemeMedia(isDark, propValue, declaration, postcss) { + let mediaQuery = postcss.atRule({ + name: 'media', + params: `(prefers-color-scheme:${isDark ? 'dark' : 'light'})` + }) + mediaQuery.append( + postcss.rule({ + nodes: [ + postcss.decl({ + prop: declaration.prop, + value: propValue + }) + ], + selector: declaration.parent.selector + }) + ) + declaration.parent.after(mediaQuery) +} + +function replaceLightDark(isDark, declaration, stringBoundaries) { + return declaration.value.replaceAll( + LIGHT_DARK, + (match, lightColor, darkColor, offset) => { + let isInsideString = stringBoundaries.some( + boundary => offset > boundary[0] && offset < boundary[1] + ) + if (isInsideString) return match + return isDark ? darkColor : lightColor + } + ) +} + module.exports = (opts = {}) => { let dark = opts.darkSelector || '.is-dark' let light = opts.lightSelector || '.is-light' @@ -109,6 +143,30 @@ module.exports = (opts = {}) => { } } }, + DeclarationExit: (declaration, { postcss }) => { + if (!declaration.value.includes('light-dark')) return + + let stringBoundaries = [] + let value = declaration.value.slice() + let match = STRING.exec(value) + while (match) { + stringBoundaries.push(match.indices[0]) + match = STRING.exec(value) + } + + let lightValue = replaceLightDark(false, declaration, stringBoundaries) + if (declaration.value === lightValue) return + let darkValue = replaceLightDark(true, declaration, stringBoundaries) + + addColorSchemeMedia(false, lightValue, declaration, postcss) + addColorSchemeMedia(true, darkValue, declaration, postcss) + + let parent = declaration.parent + declaration.remove() + if (parent.nodes.length === 0) { + parent.remove() + } + }, postcssPlugin: 'postcss-dark-theme-class' } } diff --git a/index.test.js b/index.test.js index bd2bca2..5c72ab2 100644 --- a/index.test.js +++ b/index.test.js @@ -383,4 +383,222 @@ test('ignores already transformed rules - light scheme', () => { ) }) +test('transforms light-dark()', () => { + run( + `html { + border: 1px solid light-dark(white, black) +}`, + `@media (prefers-color-scheme:dark) { + html:where(:not(.is-light)) { + border: 1px solid black + } +} +html:where(.is-dark) { + border: 1px solid black +} +@media (prefers-color-scheme:light) { + html:where(:not(.is-dark)) { + border: 1px solid white + } +} +html:where(.is-light) { + border: 1px solid white +}` + ) +}) + +test('does not transform light-dark() inside strings', () => { + run( + `html { + content: ' light-dark(white, black) \ + light-dark(purple, yellow) + '; + background: url("light-dark(red, blue).png"); + quotes: "light-dark(white, black)" "light-dark(red, green)"; +}`, + `html { + content: ' light-dark(white, black) \ + light-dark(purple, yellow) + '; + background: url("light-dark(red, blue).png"); + quotes: "light-dark(white, black)" "light-dark(red, green)"; +}` + ) +}) + +test('transforms light-dark() and disables :where() of request', () => { + run( + `section { + color: light-dark(#888, #eee) +}`, + `@media (prefers-color-scheme:dark) { + html:not(.is-light) section { + color: #eee + } +} +html.is-dark section { + color: #eee +} +@media (prefers-color-scheme:light) { + html:not(.is-dark) section { + color: #888 + } +} +html.is-light section { + color: #888 +}`, + { useWhere: false } + ) +}) + +test('processes inner at-rules with light-dark()', () => { + run( + `@media (min-width: 500px) { + @media (print) { + a { + background-color: light-dark(white, black) + } + } + }`, + `@media (min-width: 500px) { + @media (print) { + @media (prefers-color-scheme:dark) { + :where(html:not(.is-light)) a { + background-color: black + } + } + :where(html.is-dark) a { + background-color: black + } + @media (prefers-color-scheme:light) { + :where(html:not(.is-dark)) a { + background-color: white + } + } + :where(html.is-light) a { + background-color: white + } + } + }` + ) +}) + +test('ignores whitespaces for light-dark()', () => { + run( + `a { background: radial-gradient(light-dark( red , yellow ), +light-dark( white , black ), +rgb(30 144 255)); } +`, + `@media (prefers-color-scheme:dark) { + :where(html:not(.is-light)) a { + background: radial-gradient(yellow, +black, +rgb(30 144 255)) + } +} +:where(html.is-dark) a { + background: radial-gradient(yellow, +black, +rgb(30 144 255)) +} +@media (prefers-color-scheme:light) { + :where(html:not(.is-dark)) a { + background: radial-gradient(red, +white, +rgb(30 144 255)) + } +} +:where(html.is-light) a { + background: radial-gradient(red, +white, +rgb(30 144 255)) +} +` + ) +}) + +test('changes root selectors for light-dark()', () => { + run( + `html, .s { --bg: light-dark(white, black) } + p { color: light-dark(red, blue) } +`, + `@media (prefers-color-scheme:dark) { + html:where(:not(.is-light)), .s:where(:not(.is-light)) { + --bg: black + } +} +html:where(.is-dark), .s:where(.is-dark) { + --bg: black +} +@media (prefers-color-scheme:light) { + html:where(:not(.is-dark)), .s:where(:not(.is-dark)) { + --bg: white + } +} +html:where(.is-light), .s:where(.is-light) { + --bg: white +} + @media (prefers-color-scheme:dark) { + :where(html:not(.is-light)) p,:where(.s:not(.is-light)) p { + color: blue + } +} + :where(html.is-dark) p,:where(.s.is-dark) p { + color: blue +} + @media (prefers-color-scheme:light) { + :where(html:not(.is-dark)) p,:where(.s:not(.is-dark)) p { + color: red + } +} + :where(html.is-light) p,:where(.s.is-light) p { + color: red +} +`, + { rootSelector: ['html', ':root', '.s'] } + ) +}) + +test('changes root selector for light-dark()', () => { + run( + `body { --bg: light-dark(white, black) } + p { color: light-dark(green, yellow) } +`, + `@media (prefers-color-scheme:dark) { + body:where(:not(.is-light)) { + --bg: black + } +} +body:where(.is-dark) { + --bg: black +} +@media (prefers-color-scheme:light) { + body:where(:not(.is-dark)) { + --bg: white + } +} +body:where(.is-light) { + --bg: white +} + @media (prefers-color-scheme:dark) { + :where(body:not(.is-light)) p { + color: yellow + } +} + :where(body.is-dark) p { + color: yellow +} + @media (prefers-color-scheme:light) { + :where(body:not(.is-dark)) p { + color: green + } +} + :where(body.is-light) p { + color: green +} +`, + { rootSelector: 'body' } + ) +}) + test.run()