diff --git a/.eslintrc.json b/.eslintrc.json
index 82c504e..1e92946 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -1,26 +1,35 @@
{
"extends": [
"eslint:recommended",
- "plugin:jsdoc/recommended",
- "plugin:node/recommended",
- "plugin:import/recommended",
- "plugin:compat/recommended",
"plugin:react/recommended",
- "plugin:prettier/recommended"
+ "plugin:react-hooks/recommended"
],
- "plugins": ["jsdoc", "react-hooks"],
"parserOptions": {
- "ecmaVersion": "latest"
+ "ecmaVersion": "latest",
+ "sourceType": "script"
},
"settings": {
"react": {
"version": "detect"
}
},
+ "env": {
+ "browser": true,
+ "node": true,
+ "es2022": true
+ },
+ "overrides": [
+ {
+ "files": ["**/*.mjs"],
+ "parserOptions": {
+ "ecmaVersion": "latest",
+ "sourceType": "module"
+ }
+ }
+ ],
"rules": {
"arrow-body-style": ["error", "always"],
"curly": ["error", "all"],
- "jsdoc/require-jsdoc": "off",
"func-style": ["error", "declaration"]
}
}
diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml
index 0ea4d72..be15b23 100644
--- a/.github/workflows/nodejs.yml
+++ b/.github/workflows/nodejs.yml
@@ -6,8 +6,8 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- os: [ubuntu-latest, macos-latest]
- node: ["14", "16", "18", "19"]
+ os: [ubuntu-latest]
+ node: ["18", "19"]
steps:
- uses: actions/checkout@v3
- name: Setup Node.js v${{ matrix.node }}
diff --git a/.prettierignore b/.prettierignore
index 87925bc..ec6d3cd 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,2 +1 @@
package.json
-/snapshots
diff --git a/Global.js b/Global.js
index b6081c6..ae575ed 100644
--- a/Global.js
+++ b/Global.js
@@ -3,13 +3,12 @@
const { Global: EmotionGlobal } = require("@emotion/react");
const PropTypes = require("prop-types");
const React = require("react");
-const transformStyles = require("./private/transformStyles.js");
+const css = require("./private/css.js");
const useMystical = require("./useMystical.js");
-function Global({ styles: initialStyles }) {
+function Global({ styles }) {
const context = useMystical();
- const styles = transformStyles(initialStyles)(context);
- return React.createElement(EmotionGlobal, { styles });
+ return React.createElement(EmotionGlobal, { styles: css(styles)(context) });
}
Global.propTypes = {
diff --git a/MysticalProvider.js b/MysticalProvider.js
index 9a36be5..cfba370 100644
--- a/MysticalProvider.js
+++ b/MysticalProvider.js
@@ -5,26 +5,39 @@ const PropTypes = require("prop-types");
const React = require("react");
const Global = require("./Global.js");
const customProperties = require("./private/customProperties.js");
+const useMystical = require("./useMystical.js");
-function MysticalProvider({ theme = {}, children }) {
- return React.createElement(
- ThemeProvider,
- { theme: { theme } },
- React.createElement(Global, {
- styles: {
- ":root": customProperties(theme.colors, "colors"),
+function MysticalGlobalStyles() {
+ const { theme } = useMystical();
+
+ return React.createElement(Global, {
+ styles: [
+ {
["*, *::before,*::after"]: {
boxSizing: "border-box",
},
- ...theme.global,
},
- }),
+ theme.colors && { ":root": customProperties(theme.colors, "colors") },
+ theme.global,
+ ],
+ });
+}
+
+function MysticalProvider({ theme, options = {}, children }) {
+ return React.createElement(
+ ThemeProvider,
+ { theme: { theme, options } },
+ React.createElement(MysticalGlobalStyles),
children
);
}
MysticalProvider.propTypes = {
- theme: PropTypes.object.isRequired,
+ theme: PropTypes.oneOfType([PropTypes.object, PropTypes.func]).isRequired,
+ options: PropTypes.shape({
+ darkModeOff: PropTypes.bool,
+ darkModeForcedBoundary: PropTypes.bool,
+ }),
children: PropTypes.node.isRequired,
};
diff --git a/darkColorMode.js b/darkColorMode.js
index 4699efb..97fd82f 100644
--- a/darkColorMode.js
+++ b/darkColorMode.js
@@ -1,5 +1,5 @@
"use strict";
-const darkColorMode = "@media (prefers-color-scheme: dark)";
+const darkColorMode = "__MYSTICAL_INTERNAL_DARK_MODE__";
module.exports = darkColorMode;
diff --git a/package.json b/package.json
index 9a4a8c8..d280fdb 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "mystical",
"description": "CSS prop constraint based styling",
- "version": "14.0.0",
+ "version": "15.0.0-beta.2",
"author": "David Burles",
"license": "MIT",
"repository": "github:dburles/mystical",
@@ -15,22 +15,20 @@
"react"
],
"engines": {
- "node": "^14.17.0 || ^16.0.0 || >= 18.0.0"
+ "node": "^18.0.0 || >= 19.0.0"
},
- "browserslist": "Node 14.17 - 15 and Node < 15, Node >= 16, > 0.5%, not OperaMini all, not IE > 0, not dead",
+ "type": "commonjs",
"sideEffects": false,
"files": [
"darkColorMode.js",
"defaultColorMode.js",
"Global.js",
"index.d.ts",
- "InitializeColorMode.js",
"jsx-dev-runtime.js",
"jsx-runtime.js",
"jsx.js",
"MysticalProvider.js",
"private",
- "useColorMode.js",
"useModifiers.js",
"useMystical.js",
"useTheme.js"
@@ -40,13 +38,11 @@
"./defaultColorMode.js": "./defaultColorMode.js",
"./Global.js": "./Global.js",
"./index.d.ts": "./index.d.ts",
- "./InitializeColorMode.js": "./InitializeColorMode.js",
"./jsx-dev-runtime": "./jsx-dev-runtime.js",
"./jsx-runtime": "./jsx-runtime.js",
"./jsx.js": "./jsx.js",
"./MysticalProvider.js": "./MysticalProvider.js",
"./package.json": "./package.json",
- "./useColorMode.js": "./useColorMode.js",
"./useModifiers.js": "./useModifiers.js",
"./useMystical.js": "./useMystical.js",
"./useTheme.js": "./useTheme.js"
@@ -56,25 +52,18 @@
"test": "npm run test:lint && npm run test:prettier && npm run test:unit",
"test:lint": "eslint .",
"test:prettier": "prettier -c .",
- "test:unit": "node test/run.mjs",
+ "test:unit": "node --test",
"prepublishOnly": "npm test"
},
"devDependencies": {
"eslint": "^8.8.0",
"eslint-config-prettier": "^8.3.0",
- "eslint-plugin-compat": "^4.0.2",
- "eslint-plugin-import": "^2.23.3",
- "eslint-plugin-jsdoc": "^39.3.6",
- "eslint-plugin-node": "^11.1.0",
- "eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.23.2",
"eslint-plugin-react-hooks": "^4.2.0",
"prettier": "^2.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "react-test-renderer": "^18.2.0",
- "snapshot-assertion": "^5.0.0",
- "test-director": "^10.0.0"
+ "react-test-renderer": "^18.2.0"
},
"dependencies": {
"@emotion/react": "^11.4.0",
diff --git a/private/createJsxFn.js b/private/createJsxFn.js
index be1c9c9..782a688 100644
--- a/private/createJsxFn.js
+++ b/private/createJsxFn.js
@@ -1,14 +1,14 @@
"use strict";
-const transformStyles = require("./transformStyles.js");
+const css = require("./css.js");
function createJsxFn(jsxFn) {
return (type, props, ...rest) => {
- const { css, ...restProps } = props || {};
+ const { css: styles, ...restProps } = props || {};
return jsxFn(
type,
- css ? { css: transformStyles(css), ...restProps } : props,
+ styles ? { css: css(styles), ...restProps } : props,
...rest
);
};
diff --git a/private/css.js b/private/css.js
new file mode 100644
index 0000000..d9e6ccb
--- /dev/null
+++ b/private/css.js
@@ -0,0 +1,82 @@
+"use strict";
+
+const darkColorMode = require("../darkColorMode.js");
+const facepaint = require("./facepaint.js");
+const forceDarkModeAttribute = require("./forceDarkModeAttribute.js");
+const isObject = require("./isObject.js");
+const merge = require("./merge.js");
+const negativeTransform = require("./negativeTransform.js");
+const shorthandProperties = require("./shorthandProperties.js");
+const themeTokens = require("./themeTokens.js");
+const transformColors = require("./transformColors.js");
+
+function transformStyle(property, value, theme = {}) {
+ const themeKey = themeTokens[property];
+
+ if (shorthandProperties[property]) {
+ return shorthandProperties[property](theme, String(value));
+ } else if (themeKey) {
+ let currentThemeProperties = theme[themeKey];
+ if (themeKey === "colors") {
+ return transformColors(theme.colors, property, value);
+ }
+ const transformNegatives = negativeTransform(property);
+ return {
+ [property]: transformNegatives(currentThemeProperties, value, value),
+ };
+ }
+ return { [property]: value };
+}
+
+function css(rootStyles) {
+ let mergedStyles = Array.isArray(rootStyles)
+ ? merge(...rootStyles)
+ : rootStyles;
+
+ return (context) => {
+ let transformedStyles = {};
+
+ function transformStyles(styles) {
+ for (const property in styles) {
+ const value = styles[property];
+ if (isObject(value)) {
+ if (property === darkColorMode) {
+ const transformed = css(value)(context);
+ if (!context.options?.darkModeOff) {
+ transformedStyles["@media (prefers-color-scheme: dark)"] =
+ transformed;
+ }
+ if (context.options?.darkModeForcedBoundary) {
+ transformedStyles[`[${forceDarkModeAttribute}] &`] = transformed;
+ }
+ } else {
+ transformedStyles[property] = css(value)(context);
+ }
+ } else {
+ transformedStyles = {
+ ...transformedStyles,
+ ...transformStyle(property, value, context.theme),
+ };
+ }
+ }
+ }
+
+ if (Array.isArray(context.theme?.breakpoints)) {
+ const mq = facepaint(
+ context.theme.breakpoints.map((bp) => {
+ return `@media (min-width: ${bp})`;
+ })
+ );
+
+ for (const styles of mq(mergedStyles)) {
+ transformStyles(styles);
+ }
+ } else {
+ transformStyles(mergedStyles);
+ }
+
+ return transformedStyles;
+ };
+}
+
+module.exports = css;
diff --git a/private/css.test.mjs b/private/css.test.mjs
new file mode 100644
index 0000000..a8cf505
--- /dev/null
+++ b/private/css.test.mjs
@@ -0,0 +1,92 @@
+import test from "node:test";
+import css from "./css.js";
+import theme from "../test-utils/theme.mjs";
+import assert from "node:assert/strict";
+import darkColorMode from "../darkColorMode.js";
+import forceDarkModeAttribute from "./forceDarkModeAttribute.js";
+
+test("css", async (t) => {
+ await t.test("colors", () => {
+ const styles = css({
+ borderColor: "orange.500",
+ color: "orange.500",
+ })({ theme });
+
+ assert.deepEqual(styles, {
+ borderColor: "var(--colors-orange-500)",
+ color: "var(--colors-orange-500)",
+ });
+ });
+
+ await t.test("media query", () => {
+ const styles = css({
+ color: ["orange.500", "blue.500", "red.500", "pink.500"],
+ })({ theme });
+
+ assert.deepEqual(styles, {
+ color: "var(--colors-orange-500)",
+ "@media (min-width: 640px)": { color: "var(--colors-blue-500)" },
+ "@media (min-width: 768px)": { color: "var(--colors-red-500)" },
+ "@media (min-width: 1024px)": { color: "var(--colors-pink-500)" },
+ });
+ });
+
+ await t.test("no breakpoints defined", () => {
+ // eslint-disable-next-line no-unused-vars
+ const { breakpoints, ...themeWithoutBreakpoints } = theme;
+ // Becomes a fallback: https://emotion.sh/docs/object-styles#fallbacks
+ const styles = css({
+ color: ["orange.500", "blue.500", "red.500", "pink.500"],
+ })({ theme: themeWithoutBreakpoints });
+
+ assert.deepEqual(styles, {
+ color: ["orange.500", "blue.500", "red.500", "pink.500"],
+ });
+ });
+
+ await t.test("dark mode", async (tt) => {
+ await tt.test("transform", () => {
+ const styles = css({
+ [darkColorMode]: {
+ color: "red",
+ },
+ })({});
+
+ assert.deepEqual(styles, {
+ "@media (prefers-color-scheme: dark)": {
+ color: "red",
+ },
+ });
+ });
+
+ await tt.test("transform with darkModeOff = true", () => {
+ const styles = css({
+ [darkColorMode]: {
+ color: "red",
+ },
+ })({ options: { darkModeOff: true } });
+
+ assert.deepEqual(styles, {});
+ });
+
+ await tt.test(
+ "transform with options.darkModeForcedBoundary = true",
+ () => {
+ const styles = css({
+ [darkColorMode]: {
+ color: "red",
+ },
+ })({ options: { darkModeForcedBoundary: true } });
+
+ assert.deepEqual(styles, {
+ "@media (prefers-color-scheme: dark)": {
+ color: "red",
+ },
+ [`[${forceDarkModeAttribute}] &`]: {
+ color: "red",
+ },
+ });
+ }
+ );
+ });
+});
diff --git a/private/customProperties.test.mjs b/private/customProperties.test.mjs
new file mode 100644
index 0000000..a3672b0
--- /dev/null
+++ b/private/customProperties.test.mjs
@@ -0,0 +1,53 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import customProperties from "./customProperties.js";
+
+test("customProperties", async (t) => {
+ await t.test("self referencing", () => {
+ const colors = {
+ brand: {
+ primary: "emerald.500",
+ },
+ emerald: {
+ 50: "#ecfdf5",
+ 100: "#d1fae5",
+ 200: "#a7f3d0",
+ 300: "#6ee7b7",
+ 400: "#34d399",
+ 500: "#10b981",
+ 600: "#059669",
+ 700: "#047857",
+ 800: "#065f46",
+ 900: "#064e3b",
+ },
+ };
+
+ const result = customProperties(colors, "colors");
+ assert.equal(result["--colors-brand-primary"], colors.emerald["500"]);
+ });
+
+ await t.test("self referencing (array)", () => {
+ const colors = {
+ brand: {
+ primary: ["emerald.500", "emerald.100"],
+ },
+ emerald: {
+ 50: "#ecfdf5",
+ 100: "#d1fae5",
+ 200: "#a7f3d0",
+ 300: "#6ee7b7",
+ 400: "#34d399",
+ 500: "#10b981",
+ 600: "#059669",
+ 700: "#047857",
+ 800: "#065f46",
+ 900: "#064e3b",
+ },
+ };
+
+ const result = customProperties(colors, "colors");
+
+ assert.equal(result["--colors-brand-primary-0"], colors.emerald["500"]);
+ assert.equal(result["--colors-brand-primary-1"], colors.emerald["100"]);
+ });
+});
diff --git a/private/forceDarkModeAttribute.js b/private/forceDarkModeAttribute.js
new file mode 100644
index 0000000..80fe3e5
--- /dev/null
+++ b/private/forceDarkModeAttribute.js
@@ -0,0 +1,5 @@
+"use strict";
+
+const forceDarkModeAttribute = 'data-mystical-color-mode="dark"';
+
+module.exports = forceDarkModeAttribute;
diff --git a/private/mergeModifiers.test.mjs b/private/mergeModifiers.test.mjs
new file mode 100644
index 0000000..7854a9c
--- /dev/null
+++ b/private/mergeModifiers.test.mjs
@@ -0,0 +1,74 @@
+import assert from "node:assert/strict";
+import mergeModifiers from "./mergeModifiers.js";
+import test from "node:test";
+
+const modifiers = {
+ default: {
+ fontFamily: "heading",
+ },
+ size: {
+ small: {
+ fontSize: 3,
+ },
+ large: {
+ fontSize: 5,
+ },
+ },
+};
+
+const modifiers2 = {
+ default: {
+ title: { fontFamily: "heading" },
+ subtitle: { fontFamily: "body" },
+ },
+ size: {
+ small: {
+ title: { fontSize: 3 },
+ subtitle: { fontSize: 0 },
+ },
+ large: {
+ title: { fontSize: 5 },
+ subtitle: { fontSize: 2 },
+ },
+ },
+};
+
+test("mergeModifiers", async (t) => {
+ await t.test("basic", async () => {
+ const modifierStyles = mergeModifiers({ size: "small" }, modifiers);
+
+ assert.equal(Object.keys(modifierStyles).length, 2);
+ assert.equal(modifierStyles.fontFamily, "heading");
+ assert.equal(modifierStyles.fontSize, 3);
+ });
+
+ await t.test("basic with customModifiers", async () => {
+ const modifierStyles = mergeModifiers({ size: "small" }, modifiers, {
+ fontSize: 4,
+ });
+
+ assert.equal(Object.keys(modifierStyles).length, 2);
+ assert.equal(modifierStyles.fontFamily, "heading");
+ assert.equal(modifierStyles.fontSize, 4);
+ });
+
+ await t.test("multiple elements", async () => {
+ const modifierStyles = mergeModifiers({ size: "small" }, modifiers2);
+
+ assert.equal(Object.keys(modifierStyles).length, 2);
+ assert.equal(modifierStyles.title.fontFamily, "heading");
+ assert.equal(modifierStyles.subtitle.fontFamily, "body");
+ assert.equal(modifierStyles.title.fontSize, 3);
+ assert.equal(modifierStyles.subtitle.fontSize, 0);
+ });
+
+ await t.test("multiple elements with customModifiers", async () => {
+ const modifierStyles = mergeModifiers({ size: "small" }, modifiers2, {
+ title: {
+ fontSize: 4,
+ },
+ });
+
+ assert.equal(modifierStyles.title.fontSize, 4);
+ });
+});
diff --git a/private/transformStyles.js b/private/transformStyles.js
deleted file mode 100644
index 5c0b51e..0000000
--- a/private/transformStyles.js
+++ /dev/null
@@ -1,57 +0,0 @@
-"use strict";
-
-const facepaint = require("./facepaint.js");
-const isObject = require("./isObject.js");
-const merge = require("./merge.js");
-const negativeTransform = require("./negativeTransform.js");
-const shorthandProperties = require("./shorthandProperties.js");
-const themeTokens = require("./themeTokens.js");
-const transformColors = require("./transformColors.js");
-
-function transformStyle(key, value, { theme }) {
- const themeKey = themeTokens[key];
-
- if (shorthandProperties[key]) {
- return shorthandProperties[key](theme, String(value));
- } else if (themeKey) {
- let currentThemeProperties = theme[themeKey];
- if (themeKey === "colors") {
- return transformColors(theme.colors, key, value);
- }
- const transformNegatives = negativeTransform(key);
- return { [key]: transformNegatives(currentThemeProperties, value, value) };
- }
- return { [key]: value };
-}
-
-function transformStyles(initialStyles) {
- return (context) => {
- let transformedStyles = {};
-
- const mq = facepaint(
- context.theme.breakpoints.map((bp) => {
- return `@media (min-width: ${bp})`;
- })
- );
-
- mq(
- Array.isArray(initialStyles) ? merge(...initialStyles) : initialStyles
- ).forEach((styles) => {
- Object.keys(styles).forEach((key) => {
- const value = styles[key];
- if (isObject(value)) {
- transformedStyles[key] = transformStyles(value)(context);
- } else {
- transformedStyles = {
- ...transformedStyles,
- ...transformStyle(key, value, context),
- };
- }
- });
- });
-
- return transformedStyles;
- };
-}
-
-module.exports = transformStyles;
diff --git a/readme.md b/readme.md
index 03388fa..0dd434c 100644
--- a/readme.md
+++ b/readme.md
@@ -193,7 +193,7 @@ function Component() {
##### Theme Lookup
-Just like [theme-ui](https://theme-ui.com/), values passed to CSS properties are automatically translated from the theme based on a [lookup map](https://github.com/dburles/mystical/blob/master/src/lib/themeTokens.js), and will default to the literal value if there's no match.
+Just like [theme-ui](https://theme-ui.com/), values passed to CSS properties are automatically translated from the theme based on a [lookup table](https://github.com/dburles/mystical/blob/master/private/themeTokens.js), and will default to the literal value if there's no match.
##### Dot Properties
@@ -254,26 +254,31 @@ function Component() {
#### MysticalProvider
-Your application must be wrapped with the `MysticalProvider` component:
+Provides the theme context, this is required for Mystical to function.
-```js
-import MysticalProvider from "mystical/MysticalProvider.js";
-
-function App() {
- return