diff --git a/src/internals/hooks.js b/src/internals/hooks.js new file mode 100644 index 0000000..af0ea4a --- /dev/null +++ b/src/internals/hooks.js @@ -0,0 +1,144 @@ +import { useMemo, useRef } from 'react'; +import { PixelRatio, useWindowDimensions } from 'react-native'; +import { resolveMediaRangeQueries } from './media'; +import { processStyleSheet } from './styles'; +import { getCompoundKey } from './utils'; + +/** + * @param {Record} media Media queries defined in the Stitches config. + * { + * bp1: '(width <= 480px)', + * bp2: '(width <= 768px)', + * bp3: '(width <= 1024px)', + * phone: !DeviceInfo.isTablet(), + * tablet: DeviceInfo.isTablet(), + * } + */ +export function useMediaQueries(media) { + const { width: windowWidth } = useWindowDimensions(); + + return useMemo(() => { + const correctedWidth = PixelRatio.getPixelSizeForLayoutSize(windowWidth); + return resolveMediaRangeQueries(media, correctedWidth); + }, [windowWidth]); // eslint-disable-line react-hooks/exhaustive-deps +} + +export function useProcessedStyleSheet({ + media, + activeMediaQueries, + styleSheet, +}) { + return useMemo(() => { + return processStyleSheet(styleSheet, media, activeMediaQueries); + }, [styleSheet, activeMediaQueries]); // eslint-disable-line react-hooks/exhaustive-deps +} + +export function useVariantStyles({ + props: _props, + variants, + defaultVariants, + media, + styleSheet, + activeMediaQueries, +}) { + // Only recalculate if the variant props have changed + const props = useStableVariantProps(variants, _props); + + return useMemo(() => { + if (!variants || Object.keys(variants).length === 0) return []; + + return Object.keys(variants) + .map((prop) => { + let variantStyle = {}; + let propValue = props[prop]; + + if (propValue === undefined) { + propValue = defaultVariants[prop]; + } + + // Handle responsive prop value, eg: + // `` + if (typeof propValue === 'object' && typeof media === 'object') { + const initial = propValue['@initial']; + + if (initial !== undefined && styleSheet[`${prop}_${initial}`]) { + variantStyle = { + ...variantStyle, + ...styleSheet[`${prop}_${initial}`], + }; + } + + // NOTE: there can be multiple media queries active at the same time + // depending on how the media queries are defined! + // Apply responsive styles in the order of active media queries + // so that later ones overwrite earlier ones. + if (activeMediaQueries.length > 0) { + activeMediaQueries.forEach((mediaKey) => { + const value = propValue[`@${mediaKey}`]; + + if (value !== undefined && styleSheet[`${prop}_${value}`]) { + variantStyle = { + ...variantStyle, + ...styleSheet[`${prop}_${value}`], + }; + } + }); + } + } else { + // Eg. `kind_heading` or `color_primary` + variantStyle = styleSheet[`${prop}_${propValue}`]; + } + + return variantStyle; + }) + .filter(Boolean); + }, [props, activeMediaQueries, styleSheet]); // eslint-disable-line react-hooks/exhaustive-deps +} + +export function useCompoundVariantStyles({ + props: _props, + variants, + defaultVariants, + compoundVariants, + styleSheet, +}) { + // Only recalculate if the variant props have changed + const props = useStableVariantProps(variants, _props); + + return useMemo(() => { + if (!compoundVariants || compoundVariants.length === 0) return []; + + return compoundVariants + .map(({ css: _css, ...compounds }) => { + const compoundEntries = Object.entries(compounds); + const compoundActive = compoundEntries.every(([prop, value]) => { + const propValue = props[prop] ?? defaultVariants[prop]; + return propValue === value; + }); + + if (compoundActive) return styleSheet[getCompoundKey(compoundEntries)]; + }) + .filter(Boolean); + }, [props, styleSheet]); // eslint-disable-line react-hooks/exhaustive-deps +} + +// Helpers --------------------------------------------------------------------- + +function variantPropsEqual(variants, props) { + const variantKeys = Object.keys(variants); + for (let i = 0; i < variantKeys.length; i++) { + const key = variantKeys[i]; + if (variants[key] !== props[key]) return false; + } + return true; +} + +function useStableVariantProps(variants, props) { + const propsRef = useRef(); + + if (!propsRef.current || !variantPropsEqual(variants, propsRef.current)) { + propsRef.current = props; + } + + return propsRef.current; +} diff --git a/src/internals/index.js b/src/internals/index.js index 4125c6f..e32441e 100644 --- a/src/internals/index.js +++ b/src/internals/index.js @@ -1,5 +1,4 @@ import merge from 'lodash.merge'; -import { PixelRatio, useWindowDimensions } from 'react-native'; import React, { createContext, @@ -10,8 +9,27 @@ import React, { useContext, } from 'react'; -import * as utils from './utils'; -import * as constants from './constants'; +import { + flattenCompoundVariantStyles, + flattenStyles, + flattenVariantStyles, +} from './utils'; + +import { + DEFAULT_THEME_MAP, + EMPTY_THEME, + THEME_PROVIDER_MISSING_MESSAGE, +} from './constants'; + +import { + useCompoundVariantStyles, + useMediaQueries, + useProcessedStyleSheet, + useVariantStyles, +} from './hooks'; + +import { processTheme } from './theme'; +import { createStyleSheet, processStyles } from './styles'; /** @typedef {import('../types').__Stitches__} Stitches */ /** @typedef {import('../types').CreateStitches} CreateStitches */ @@ -24,17 +42,17 @@ export function createStitches(config = {}) { const themes = []; if (config.theme) { - const processedTheme = utils.processTheme(config.theme); + const processedTheme = processTheme(config.theme); processedTheme.definition.__ID__ = 'theme-1'; themes.push(processedTheme); } else { - themes.push(constants.EMPTY_THEME); + themes.push(EMPTY_THEME); } /** @type {Stitches['createTheme']} */ function createTheme(theme) { - const newTheme = utils.processTheme( + const newTheme = processTheme( Object.entries(config.theme || {}).reduce((acc, [key, val]) => { acc[key] = { ...val, ...theme[key] }; return acc; @@ -62,10 +80,10 @@ export function createStitches(config = {}) { const themeDefinition = useContext(ThemeContext); if (!themeDefinition) { - throw new Error(constants.THEME_PROVIDER_MISSING_MESSAGE); + throw new Error(THEME_PROVIDER_MISSING_MESSAGE); } - return themes.find((x) => x.definition.__ID__ === themeDefinition.__ID__); + return themes.find((t) => t.definition.__ID__ === themeDefinition.__ID__); } /** @type {Stitches['useTheme']} */ @@ -73,25 +91,28 @@ export function createStitches(config = {}) { const themeDefinition = useContext(ThemeContext); if (!themeDefinition) { - throw new Error(constants.THEME_PROVIDER_MISSING_MESSAGE); + throw new Error(THEME_PROVIDER_MISSING_MESSAGE); } - return themes.find((x) => x.definition.__ID__ === themeDefinition.__ID__).values; // prettier-ignore + return themes.find((t) => t.definition.__ID__ === themeDefinition.__ID__).values; // prettier-ignore } /** @type {Stitches['styled']} */ - function styled(component, ...styleObjects) { - const styleObject = styleObjects.reduce((a, v) => merge(a, v), {}); + function styled(component, ...styleDefs) { + const styleDef = styleDefs.reduce((a, v) => merge(a, v), {}); const { - variants = {}, - compoundVariants = [], defaultVariants = {}, + variants: _variants, + compoundVariants: _compoundVariants, ..._styles - } = styleObject; - - const styles = _styles; + } = styleDef; + const media = config.media || {}; + const utils = config.utils || {}; + const variants = flattenVariantStyles(_variants || {}, utils); + const compoundVariants = flattenCompoundVariantStyles(_compoundVariants || [], utils); // prettier-ignore + const styles = flattenStyles(_styles || {}, utils); const styleSheets = {}; let attrsFn; @@ -99,151 +120,66 @@ export function createStitches(config = {}) { let Comp = forwardRef((props, ref) => { const theme = useThemeInternal(); - const styleSheet = useMemo(() => { - const _styleSheet = styleSheets[theme.definition.__ID__]; - if (_styleSheet) { - return _styleSheet; - } - styleSheets[theme.definition.__ID__] = utils.createStyleSheet({ + const baseStyleSheet = useMemo(() => { + const existingSheet = styleSheets[theme.definition.__ID__]; + if (existingSheet) return existingSheet; + + styleSheets[theme.definition.__ID__] = createStyleSheet({ styles, - config, - theme, variants, compoundVariants, + theme: theme.values, + themeMap: config.themeMap, }); + return styleSheets[theme.definition.__ID__]; }, [theme]); - const { width: windowWidth } = useWindowDimensions(); - - let variantStyles = []; - let compoundVariantStyles = []; - - const { mediaKey, breakpoint } = useMemo(() => { - if (typeof config.media === 'object') { - const correctedWindowWidth = - PixelRatio.getPixelSizeForLayoutSize(windowWidth); - - // TODO: how do we quarantee the order of breakpoint matches? - // The order of the media key value pairs should be constant - // but is that guaranteed? So if the keys are ordered from - // smallest screen size to largest everything should work ok... - const _mediaKey = utils.resolveMediaRangeQuery( - config.media, - correctedWindowWidth - ); - - return { - mediaKey: _mediaKey, - breakpoint: _mediaKey && `@${_mediaKey}`, - }; - } - - return {}; - }, [windowWidth]); - - if (variants) { - variantStyles = Object.keys(variants) - .map((prop) => { - let propValue = props[prop]; - - if (propValue === undefined) { - propValue = defaultVariants[prop]; - } - - let styleSheetKey = `${prop}_${propValue}`; - - // Handle responsive prop value - // NOTE: only one media query will be applied since the `styleSheetKey` - // is being rewritten by the last matching media query and defaults to `@initial` - if ( - typeof propValue === 'object' && - typeof config.media === 'object' - ) { - // `@initial` acts as the default value if none of the media query values match - // It's basically the as setting `prop="value"`, eg. `color="primary"` - if (typeof propValue['@initial'] === 'string') { - styleSheetKey = `${prop}_${propValue['@initial']}`; - } - - if (breakpoint && propValue[breakpoint] !== undefined) { - const val = config.media[mediaKey]; - - if (val === true || typeof val === 'string') { - styleSheetKey = `${prop}_${propValue[breakpoint]}`; - } - } - } - - const extractedStyle = styleSheetKey - ? styleSheet[styleSheetKey] - : undefined; - - if (extractedStyle && breakpoint in extractedStyle) { - // WARNING: lodash merge modifies the first argument reference or skips if object is frozen. - return merge({}, extractedStyle, extractedStyle[breakpoint]); - } - - return extractedStyle; - }) - .filter(Boolean); - } - - if (compoundVariants) { - compoundVariantStyles = compoundVariants - .map((compoundVariant) => { - // eslint-disable-next-line - const { css: _css, ...compounds } = compoundVariant; - const compoundEntries = Object.entries(compounds); - - if ( - compoundEntries.every(([prop, value]) => { - const propValue = props[prop] ?? defaultVariants[prop]; - return propValue === value; - }) - ) { - const key = utils.getCompoundKey(compoundEntries); - const extractedStyle = styleSheet[key]; - - if (extractedStyle && breakpoint in extractedStyle) { - // WARNING: lodash merge modifies the first argument reference or skips if object is frozen. - return merge({}, extractedStyle, extractedStyle[breakpoint]); - } - - return extractedStyle; - } - }) - .filter(Boolean); - } - - let cssStyles = props.css - ? utils.processStyles({ - styles: props.css || {}, + const activeMediaQueries = useMediaQueries(media); + + const styleSheet = useProcessedStyleSheet({ + media, + activeMediaQueries, + styleSheet: baseStyleSheet, + }); + + const variantStyles = useVariantStyles({ + props, + variants, + defaultVariants, + media, + activeMediaQueries, + styleSheet, + }); + + const compoundVariantStyles = useCompoundVariantStyles({ + props, + variants, + defaultVariants, + compoundVariants, + styleSheet, + }); + + const cssStyles = props.css + ? processStyles({ + styles: flattenStyles(props.css || {}, utils), theme: theme.values, - config, + themeMap: config.themeMap, }) : {}; - if (cssStyles && breakpoint in cssStyles) { - // WARNING: lodash merge modifies the first argument reference or skips if object is frozen. - cssStyles = merge({}, cssStyles, cssStyles[breakpoint]); - } - - const mediaStyle = styleSheet.base[breakpoint] || {}; - - const stitchesStyles = [ + const combinedStyles = [ styleSheet.base, - mediaStyle, ...variantStyles, ...compoundVariantStyles, cssStyles, ]; - const allStyles = + const style = typeof props.style === 'function' ? (...rest) => - [props.style(...rest), ...stitchesStyles].filter(Boolean) - : [...stitchesStyles, props.style].filter(Boolean); + [props.style(...rest), ...combinedStyles].filter(Boolean) + : [...combinedStyles, props.style].filter(Boolean); let attrsProps = {}; @@ -260,7 +196,7 @@ export function createStitches(config = {}) { const componentProps = { ...attrsProps, ...propsWithoutVariant, - style: allStyles, + style, ref, }; @@ -306,6 +242,6 @@ export function createStitches(config = {}) { export const { styled, css } = createStitches(); -export const defaultThemeMap = constants.DEFAULT_THEME_MAP; +export const defaultThemeMap = DEFAULT_THEME_MAP; export default createStitches; diff --git a/src/internals/media.js b/src/internals/media.js new file mode 100644 index 0000000..3cef3ca --- /dev/null +++ b/src/internals/media.js @@ -0,0 +1,56 @@ +export function resolveMediaRangeQueries(media, width) { + const activeMediaQueries = []; + + for (const [name, query] of Object.entries(media)) { + if (typeof query === 'boolean' && query) { + activeMediaQueries.push(name); + } else if ( + typeof query === 'string' && + matchMediaRangeQuery(query, width) + ) { + activeMediaQueries.push(name); + } + } + + return activeMediaQueries; +} + +const validSigns = ['<=', '<', '>=', '>']; + +function matchMediaRangeQuery(query, windowWidth) { + const singleRangeRegex = /^\(width\s+([><=]+)\s+([0-9]+)px\)$/; + const multiRangeRegex = /^\(([0-9]+)px\s([><=]+)\swidth\s+([><=]+)\s+([0-9]+)px\)$/; // prettier-ignore + const singleRangeMatches = query.match(singleRangeRegex); + const multiRangeMatches = query.match(multiRangeRegex); + + let result; + + if (multiRangeMatches && multiRangeMatches.length === 5) { + const [, _width1, sign1, sign2, _width2] = multiRangeMatches; + const width1 = parseInt(_width1, 10); + const width2 = parseInt(_width2, 10); + + if (validSigns.includes(sign1) && validSigns.includes(sign2)) { + result = eval( + `${width1} ${sign1} ${windowWidth} && ${windowWidth} ${sign2} ${width2}` + ); + } + } else if (singleRangeMatches && singleRangeMatches.length === 3) { + const [, sign, _width] = singleRangeMatches; + const width = parseInt(_width, 10); + + if (validSigns.includes(sign)) { + result = eval(`${windowWidth} ${sign} ${width}`); + } + } + + if (result === undefined) return false; + + if (typeof result !== 'boolean') { + console.warn( + `Unexpected media query result. Expected a boolean but got ${result}. Please make sure your media query syntax is correct.` + ); + } + + return result; +} diff --git a/src/internals/styles.js b/src/internals/styles.js new file mode 100644 index 0000000..79fd6dc --- /dev/null +++ b/src/internals/styles.js @@ -0,0 +1,153 @@ +import { StyleSheet } from 'react-native'; +import { DEFAULT_THEME_MAP } from './constants'; +import { getThemeKey, processThemeMap } from './theme'; +import { getCompoundKey } from './utils'; + +/** + * Process styles by replacing all theme tokens with their actual value. + * NOTE: passed styles need to be flattened before calling this! + */ +export function processStyles({ styles, theme, themeMap: customThemeMap }) { + const themeMap = processThemeMap(customThemeMap || DEFAULT_THEME_MAP); + + return Object.entries(styles).reduce((acc, [key, val]) => { + if (typeof val === 'string' && val.indexOf('$') !== -1) { + // Handle theme tokens, eg. `color: "$primary"` or `color: "$colors$primary"` + const arr = val.split('$'); + const token = arr.pop(); + const scaleOrSign = arr.pop(); + const maybeSign = arr.pop(); // handle negative values + const scale = scaleOrSign !== '-' ? scaleOrSign : undefined; + const sign = scaleOrSign === '-' || maybeSign === '-' ? -1 : undefined; + + if (scale && theme[scale]) { + acc[key] = theme[scale][token]; + } else { + const themeKey = getThemeKey(theme, themeMap, key); + if (themeKey) { + acc[key] = theme[themeKey][token]; + } + } + if (typeof acc[key] === 'number' && sign) { + acc[key] *= sign; + } + } else if (typeof val === 'object' && val.value !== undefined) { + // Handle cases where the value comes from the `theme` returned by `createStitches` + acc[key] = val.value; + } else { + // Value is a regular style value + acc[key] = val; + } + + return acc; + }, {}); +} + +export function createStyleSheet({ + theme, + themeMap, + styles, + variants, + compoundVariants, +}) { + return StyleSheet.create({ + base: styles + ? processStyles({ + styles, + theme, + themeMap, + }) + : {}, + // Variant styles + ...Object.entries(variants).reduce( + (variantsAcc, [variantProp, variantValues]) => { + Object.entries(variantValues).forEach( + ([variantName, variantStyles]) => { + // Eg. `color_primary` or `size_small` + const key = `${variantProp}_${variantName}`; + + variantsAcc[key] = processStyles({ + styles: variantStyles, + theme, + themeMap, + }); + } + ); + return variantsAcc; + }, + {} + ), + // Compound variant styles + ...compoundVariants.reduce((compoundAcc, compoundVariant) => { + const { css, ...compounds } = compoundVariant; + const compoundEntries = Object.entries(compounds); + + if (compoundEntries.length > 1) { + const key = getCompoundKey(compoundEntries); + + compoundAcc[key] = processStyles({ + styles: css || {}, + theme, + themeMap, + }); + } + + return compoundAcc; + }, {}), + }); +} + +/** + * Process the style sheet by inlining media styles. + * + * For example: + * + * prop_value: { + * color: 'red', + * md: { color: 'blue' } + * } + * + * with `md` media query being active becomes: + * + * prop_value: { + * color: 'blue' + * } + * + * @param {Record} styleSheet + * @param {string[]} activeMediaQueries + * @returns {Record} + */ +export function processStyleSheet(styleSheet, media, activeMediaQueries) { + const prosessedStyleSheet = {}; + + Object.entries(styleSheet).forEach(([sKey, sVal]) => { + prosessedStyleSheet[sKey] = {}; + + const mediaStyles = {}; + + Object.entries(sVal).forEach(([vKey, vValue]) => { + if (vKey in media) { + // Only apply media styles that are "active" + if (activeMediaQueries.includes(vKey)) { + mediaStyles[vKey] = vValue; + } + } else { + prosessedStyleSheet[sKey][vKey] = vValue; + } + }); + + // Apply media styles in right order so that "specificity" is maintained + activeMediaQueries.forEach((mediaKey) => { + const style = mediaStyles[mediaKey]; + + if (style) { + prosessedStyleSheet[sKey] = { + ...prosessedStyleSheet[sKey], + ...style, + }; + } + }); + }); + + return prosessedStyleSheet; +} diff --git a/src/internals/theme.js b/src/internals/theme.js new file mode 100644 index 0000000..b6e3b38 --- /dev/null +++ b/src/internals/theme.js @@ -0,0 +1,52 @@ +import { THEME_VALUES } from './constants'; + +export function processThemeMap(themeMap) { + const definition = {}; + + Object.keys(themeMap).forEach((token) => { + const scale = themeMap[token]; + + if (!definition[scale]) { + definition[scale] = {}; + } + + definition[scale][token] = scale; + }); + + return definition; +} + +export function processTheme(theme) { + const definition = {}; + const values = {}; + + Object.keys(theme).forEach((scale) => { + if (!definition[scale]) definition[scale] = {}; + if (!values[scale]) values[scale] = {}; + + Object.keys(theme[scale]).forEach((token) => { + let value = theme[scale][token]; + + if (typeof value === 'string' && value.length > 1 && value[0] === '$') { + value = theme[scale][value.replace('$', '')]; + } + + values[scale][token] = value; + + definition[scale][token] = { + token, + scale, + value, + toString: () => `$${token}`, + }; + }); + }); + + return { definition, values }; +} + +export function getThemeKey(theme, themeMap, key) { + return Object.keys(THEME_VALUES).find((themeKey) => { + return key in (themeMap[themeKey] || {}) && theme?.[themeKey]; + }); +} diff --git a/src/internals/utils.js b/src/internals/utils.js index 81e10d6..6fa7883 100644 --- a/src/internals/utils.js +++ b/src/internals/utils.js @@ -1,6 +1,4 @@ -import { StyleSheet } from 'react-native'; import merge from 'lodash.merge'; -import { DEFAULT_THEME_MAP, THEME_VALUES } from './constants'; export function getCompoundKey(compoundEntries) { // Eg. `color_primary+size_small` @@ -19,206 +17,244 @@ export function getCompoundKey(compoundEntries) { ); // Remove last `+` character } -const validSigns = ['<=', '<', '>=', '>']; - -function matchMediaRangeQuery(query, windowWidth) { - const singleRangeRegex = /^\(width\s+([><=]+)\s+([0-9]+)px\)$/; - const multiRangeRegex = /^\(([0-9]+)px\s([><=]+)\swidth\s+([><=]+)\s+([0-9]+)px\)$/; // prettier-ignore - const singleRangeMatches = query.match(singleRangeRegex); - const multiRangeMatches = query.match(multiRangeRegex); - - let result; - - if (multiRangeMatches && multiRangeMatches.length === 5) { - const [, _width1, sign1, sign2, _width2] = multiRangeMatches; - const width1 = parseInt(_width1, 10); - const width2 = parseInt(_width2, 10); - - if (validSigns.includes(sign1) && validSigns.includes(sign2)) { - result = eval( - `${width1} ${sign1} ${windowWidth} && ${windowWidth} ${sign2} ${width2}` - ); - } - } else if (singleRangeMatches && singleRangeMatches.length === 3) { - const [, sign, _width] = singleRangeMatches; - const width = parseInt(_width, 10); - - if (validSigns.includes(sign)) { - result = eval(`${windowWidth} ${sign} ${width}`); - } - } - - if (result === undefined) return false; - - if (typeof result !== 'boolean') { - console.warn( - `Unexpected media query result. Expected a boolean but got ${result}. Please make sure your media query syntax is correct.` - ); - } - - return result; -} - -export function resolveMediaRangeQuery(media, windowWidth) { - const entries = Object.entries(media); - let result; - - for (let i = 0; i < entries.length; i++) { - const [breakpoint, queryOrFlag] = entries[i]; - - // TODO: handle boolean flag - if (typeof queryOrFlag !== 'string') continue; - - const match = matchMediaRangeQuery(queryOrFlag, windowWidth); - - if (match) { - result = breakpoint; - } - } - - return result; -} - -export function processThemeMap(themeMap) { - const definition = {}; - - Object.keys(themeMap).forEach((token) => { - const scale = themeMap[token]; - - if (!definition[scale]) { - definition[scale] = {}; +/** + * Flatten styles so that styles from utils and media queries are recursively merged. + * + * For example: + * + * createStitches({ + * media: { + * bp1: '(min-width: 640px)', + * bp2: '(min-width: 1024px)', + * }, + * utils: { + * util1: (value) => ({ + * fontSize: value, + * '@bp1': { + * fontSize: value / 2, + * }, + * '@bp2': { + * fontSize: value * 2, + * } + * }), + * util2: (value) => ({ + * util1: 20, // <---- utils can use other utils! + * width: value, + * height: value, + * '@bp2': { + * width: value * 3, + * height: value * 3, + * } + * }), + * } + * }); + * + * const Example = styled('div', { + * color: 'red', + * util2: 100, + * '@bp1': { + * color: 'blue', + * } + * } + * + * Result: + * { + * color: 'red', + * fontSize: 20, + * width: 100, + * height: 100, + * '@bp1': { + * color: 'blue', + * fontSize: 10, + * }, + * '@bp2': { + * fontSize: 40, + * width: 300, + * height: 300, + * } + * } + */ +export function flattenStyles(styles, utils) { + let flatStyles = {}; + + Object.entries(styles).forEach(([key, val]) => { + if (key.startsWith('@')) { + const k = key.replace('@', ''); + // Media queries + if (!flatStyles[k]) { + flatStyles[k] = {}; + } + flatStyles[k] = merge(flatStyles[k], flattenStyles(val, utils)); + } else if (utils && key in utils) { + // Utils + const util = utils[key]; + flatStyles = merge(flatStyles, flattenStyles(util(val), utils)); + } else { + // Base styles + flatStyles[key] = val; } - - definition[scale][token] = scale; }); - return definition; + return flatStyles; } -export function processTheme(theme) { - const definition = {}; - const values = {}; - - Object.keys(theme).forEach((scale) => { - if (!definition[scale]) definition[scale] = {}; - if (!values[scale]) values[scale] = {}; - - Object.keys(theme[scale]).forEach((token) => { - let value = theme[scale][token]; - - if (typeof value === 'string' && value.length > 1 && value[0] === '$') { - value = theme[scale][value.replace('$', '')]; - } - - values[scale][token] = value; - - definition[scale][token] = { - token, - scale, - value, - toString: () => `$${token}`, - }; +/** + * Flatten the styles inside variant definitions so that utils and media styles are recusively merged. + * + * For example: + * + * createStitches({ + * media: { + * bp1: '(min-width: 640px)', + * bp2: '(min-width: 1024px)', + * }, + * utils: { + * util1: (value) => ({ + * fontSize: value, + * '@bp1': { + * fontSize: value / 2, + * }, + * '@bp2': { + * fontSize: value * 2, + * } + * }), + * util2: (value) => ({ + * util1: 20, + * width: value, + * height: value, + * '@bp2': { + * width: value * 3, + * height: value * 3, + * } + * }), + * } + * }); + * + * const Example = styled('div', { + * variants: { + * v1: { + * x: { + * util1: 20, + * }, + * y: { + * util2: 200, + * }, + * } + * } + * } + * + * Result: + * variants: { + * v1: { + * x: { + * fontSize: 20, + * bp1: { + * fontSize: 10, + * }, + * bp2: { + * fontSize: 40, + * } + * }, + * y: { + * width: 100, + * height: 100, + * fontSize: 20, + * bp1: { + * fontSize: 10, + * }, + * bp2: { + * fontSize: 40, + * width: 300, + * height: 300, + * }, + * }, + * }, + * } + */ +export function flattenVariantStyles(variants, utils) { + const flatVariants = {}; + + Object.entries(variants).forEach(([variantProp, variantObj]) => { + flatVariants[variantProp] = {}; + Object.entries(variantObj).forEach(([variantName, variantStyles]) => { + flatVariants[variantProp][variantName] = flattenStyles( + variantStyles, + utils + ); }); }); - return { definition, values }; + return flatVariants; } -function getThemeKey(theme, themeMap, key) { - return Object.keys(THEME_VALUES).find((themeKey) => { - return key in (themeMap[themeKey] || {}) && theme?.[themeKey]; - }); -} - -export function processStyles({ styles, theme, config }) { - const { utils, themeMap: customThemeMap } = config; - const themeMap = processThemeMap(customThemeMap || DEFAULT_THEME_MAP); - - return Object.entries(styles).reduce((acc, [key, val]) => { - if (utils && key in utils) { - // NOTE: Deep merge for media properties. - acc = merge( - acc, - processStyles({ styles: utils[key](val), theme, config }) - ); - } else if (typeof val === 'string' && val.indexOf('$') !== -1) { - // Handle theme tokens, eg. `color: "$primary"` or `color: "$colors$primary"` - const arr = val.split('$'); - const token = arr.pop(); - const scaleOrSign = arr.pop(); - const maybeSign = arr.pop(); // handle negative values - const scale = scaleOrSign !== '-' ? scaleOrSign : undefined; - const sign = scaleOrSign === '-' || maybeSign === '-' ? -1 : undefined; - - if (scale && theme[scale]) { - acc[key] = theme[scale][token]; - } else { - const themeKey = getThemeKey(theme, themeMap, key); - if (themeKey) { - acc[key] = theme[themeKey][token]; - } - } - if (typeof acc[key] === 'number' && sign) { - acc[key] *= sign; - } - } else if (typeof val === 'object' && val.value !== undefined) { - // Handle cases where the value comes from the `theme` returned by `createStitches` - acc[key] = val.value; - } else if (typeof acc[key] === 'object' && typeof val === 'object') { - // Handle cases where media object value comes from top of a style prop and variants' ones - acc[key] = merge(acc[key], val); - } else { - acc[key] = val; - } - - return acc; - }, {}); -} - -export function createStyleSheet({ - theme, - styles, - config, - variants, - compoundVariants, -}) { - return StyleSheet.create({ - base: styles ? processStyles({ styles, config, theme: theme.values }) : {}, - // Variant styles - ...Object.entries(variants).reduce( - (variantsAcc, [variantProp, variantValues]) => { - Object.entries(variantValues).forEach( - ([variantName, variantStyles]) => { - // Eg. `color_primary` or `size_small` - const key = `${variantProp}_${variantName}`; - - variantsAcc[key] = processStyles({ - styles: variantStyles, - config, - theme: theme.values, - }); - } - ); - return variantsAcc; - }, - {} - ), - // Compound variant styles - ...compoundVariants.reduce((compoundAcc, compoundVariant) => { - const { css, ...compounds } = compoundVariant; - const compoundEntries = Object.entries(compounds); - - if (compoundEntries.length > 1) { - const key = getCompoundKey(compoundEntries); - - compoundAcc[key] = processStyles({ - styles: css || {}, - config, - theme: theme.values, - }); - } - - return compoundAcc; - }, {}), +/** + * Flatten the styles inside compound variant definitions so that utils and media styles are recusively merged. + * + * For example: + * + * createStitches({ + * media: { + * bp1: '(min-width: 640px)', + * bp2: '(min-width: 1024px)', + * }, + * utils: { + * util1: (value) => ({ + * fontSize: value, + * '@bp1': { + * fontSize: value / 2, + * }, + * '@bp2': { + * fontSize: value * 2, + * } + * }), + * util2: (value) => ({ + * util1: 20, + * width: value, + * height: value, + * '@bp2': { + * width: value * 3, + * height: value * 3, + * }, + * }), + * }, + * }); + * + * compoundVariants: [{ + * v1: 'x', + * v2: 'y', + * css: { + * util2: 100, + * '@bp1': { + * color: 'blue', + * } + * } + * }] + * + * Result: + * compoundVariants: [{ + * v1: 'x', + * v2: 'y', + * css: { + * width: 100, + * height: 100, + * fontSize: 20, + * bp1: { + * color: 'blue', + * fontSize: 10, + * }, + * bp2: { + * fontSize: 40, + * width: 300, + * height: 300, + * }, + * }, + * }] + */ +export function flattenCompoundVariantStyles(compoundVariants, utils) { + return compoundVariants.map((compoundVariant) => { + return { + ...compoundVariant, + css: flattenStyles(compoundVariant.css, utils), + }; }); } diff --git a/src/tests/index.test.tsx b/src/tests/index.test.tsx index fc06d69..e5e8ca8 100644 --- a/src/tests/index.test.tsx +++ b/src/tests/index.test.tsx @@ -1,7 +1,17 @@ import React from 'react'; import { render } from '@testing-library/react-native'; + +import { + flattenStyles, + flattenVariantStyles, + flattenCompoundVariantStyles, +} from '../internals/utils'; + +import { resolveMediaRangeQueries } from '../internals/media'; +import { createStyleSheet, processStyleSheet } from '../internals/styles'; import { createStitches as _createStitches } from '../internals'; -import type { CreateStitches } from '../types'; +import { CreateStitches } from '../types'; +import { mockDimensions, reduceStyles } from './utils'; // NOTE: the JSDoc types from the internal `createStitches` are not working properly const createStitches = _createStitches as CreateStitches; @@ -152,4 +162,396 @@ describe('Runtime', () => { width: 10, }); }); + + it('Combination of all features', () => { + mockDimensions({ width: 1080 }); + + const { styled } = createStitches({ + theme: { + colors: { + primary: 'red', + secondary: 'blue', + tertiary: 'yellow', + }, + radii: { + sm: 5, + md: 10, + lg: 15, + }, + }, + media: { + md: '(width >= 750px)', + lg: '(width >= 1080px)', + xl: '(width >= 1284px)', + xxl: '(width >= 1536px)', + }, + }); + + const Comp = styled('View', { + height: 100, + width: 100, + variants: { + v1: { + one: { + backgroundColor: '$primary', + '@lg': { backgroundColor: 'black' }, + }, + two: { + backgroundColor: '$secondary', + }, + three: { + backgroundColor: '$tertiary', + }, + }, + v2: { + one: { borderColor: '$primary' }, + two: { borderColor: '$secondary' }, + three: { borderColor: '$tertiary' }, + }, + }, + compoundVariants: [ + { + v1: 'one', + v2: 'two', + css: { + borderRadius: '$sm', + borderWidth: 1, + '@md': { color: 'red' }, + }, + }, + { + v1: 'two', + v2: 'three', + css: { + borderRadius: '$lg', + borderWidth: 2, + }, + }, + ], + defaultVariants: { + v1: 'one', + v2: 'one', + }, + }); + + const { toJSON } = render(); + const result = toJSON(); + + expect(result?.type).toEqual('View'); + expect(reduceStyles(result?.props.style)).toMatchObject({ + height: 100, + width: 100, + backgroundColor: 'black', + borderColor: 'blue', + borderRadius: 5, + borderWidth: 1, + }); + }); +}); + +describe('Media', () => { + it('Nested utils and media queries with theme values', async () => { + const { styled } = createStitches({ + media: { + md: '(width >= 750px)', + lg: '(width >= 1080px)', + xl: '(width >= 1284px)', + xxl: '(width >= 1536px)', + }, + utils: { + util1: (value: number) => ({ + fontSize: value, + '@md': { fontSize: value / 2 }, + '@lg': { fontSize: value * 2 }, + }), + util2: (value: number) => ({ + util1: 20, + width: value, + height: value, + '@md': { width: value / 2, height: value / 2 }, + '@lg': { width: value * 2, height: value * 2 }, + }), + }, + }); + + const Comp = styled('View', { + backgroundColor: 'yellow', + color: 'red', + util2: 100, + '@md': { + color: 'blue', + }, + }); + + mockDimensions({ width: 640 }); + + const result1 = render().toJSON(); + + expect(reduceStyles(result1?.props.style)).toMatchObject({ + backgroundColor: 'yellow', + color: 'red', + width: 100, + height: 100, + fontSize: 20, + }); + + mockDimensions({ width: 750 }); + + const result2 = render().toJSON(); + + expect(reduceStyles(result2?.props.style)).toMatchObject({ + backgroundColor: 'yellow', + // Styles from @md + color: 'blue', // <- base media query + fontSize: 10, // <- util1 + width: 50, // <- util2 + height: 50, // <- util2 + }); + + mockDimensions({ width: 1080 }); + + const result3 = render().toJSON(); + + expect(reduceStyles(result3?.props.style)).toMatchObject({ + backgroundColor: 'yellow', + // Styles from @lg + color: 'blue', // <- base media query (applied also fo lg due to min width based media query) + fontSize: 40, // <- util1 + width: 200, // <- util2 + height: 200, // <- util2 + }); + }); +}); + +describe('Utils', () => { + const utils = { + util1: (value) => ({ + fontSize: value, + '@bp1': { fontSize: value / 2 }, + '@bp2': { fontSize: value * 2 }, + }), + util2: (value) => ({ + util1: 20, + width: value, + height: value, + '@bp2': { width: value * 3, height: value * 3 }, + }), + }; + + it('flattenStyles', () => { + const result = flattenStyles( + { + color: 'red', + util2: 100, + '@bp1': { color: 'blue' }, + }, + utils + ); + + expect(result).toMatchObject({ + color: 'red', + fontSize: 20, + width: 100, + height: 100, + bp1: { color: 'blue', fontSize: 10 }, + bp2: { fontSize: 40, width: 300, height: 300 }, + }); + }); + + it('flattenVariantStyles', () => { + const result = flattenVariantStyles( + { + v1: { + x: { util1: 10 }, + y: { util2: 100 }, + }, + }, + utils + ); + + expect(result).toMatchObject({ + v1: { + x: { + fontSize: 10, + bp1: { fontSize: 5 }, + bp2: { fontSize: 20 }, + }, + y: { + width: 100, + height: 100, + fontSize: 20, + bp1: { fontSize: 10 }, + bp2: { fontSize: 40, width: 300, height: 300 }, + }, + }, + }); + }); + + it('flattenCompoundVariantStyles', () => { + const result = flattenCompoundVariantStyles( + [ + { + v1: 'x', + v2: 'y', + css: { util2: 100, '@bp1': { color: 'blue' } }, + }, + ], + utils + ); + + expect(result).toMatchObject([ + { + v1: 'x', + v2: 'y', + css: { + width: 100, + height: 100, + fontSize: 20, + bp1: { color: 'blue', fontSize: 10 }, + bp2: { fontSize: 40, width: 300, height: 300 }, + }, + }, + ]); + }); + + it('resolveMediaRangeQueries', () => { + const media = { + bp1: '(width >= 640px)', + bp2: '(width >= 1024px)', + bp3: '(320px <= width < 1280px)', + phone: true, + tablet: false, + }; + + const result1 = resolveMediaRangeQueries(media, 640); + expect(result1).toMatchObject(['bp1', 'bp3', 'phone']); + + media.phone = false; + media.tablet = true; + + const result2 = resolveMediaRangeQueries(media, 1024); + expect(result2).toMatchObject(['bp1', 'bp2', 'bp3', 'tablet']); + + const result3 = resolveMediaRangeQueries({}, 640); + expect(result3).toMatchObject([]); + + const result4 = resolveMediaRangeQueries( + { bp1: false, bp2: false, bp3: true }, + 640 + ); + expect(result4).toMatchObject(['bp3']); + }); + + it('createStyleSheet', () => { + const theme = { + colors: { + primary: 'red', + secondary: 'blue', + tertiary: 'yellow', + }, + radii: { + sm: 5, + md: 10, + lg: 15, + }, + }; + + const result = createStyleSheet({ + theme, + themeMap: undefined, + styles: { height: 100, width: 100, sm: { height: 50, width: 50 } }, + variants: { + v1: { + one: { + backgroundColor: '$primary', + sm: { backgroundColor: 'white' }, + md: { backgroundColor: 'black' }, + lg: { backgroundColor: 'pink' }, + }, + two: { backgroundColor: '$secondary' }, + three: { backgroundColor: '$tertiary' }, + }, + v2: { + one: { borderColor: '$primary' }, + two: { borderColor: '$secondary' }, + three: { borderColor: '$tertiary' }, + }, + }, + compoundVariants: [ + { + v1: 'one', + v2: 'three', + css: { borderRadius: '$sm', borderWidth: 1 }, + }, + { + v1: 'two', + v2: 'four', + css: { borderRadius: '$lg', borderWidth: 2 }, + }, + ], + }); + + expect(result).toEqual({ + base: { height: 100, width: 100, sm: { height: 50, width: 50 } }, + v1_one: { + backgroundColor: 'red', + sm: { backgroundColor: 'white' }, + md: { backgroundColor: 'black' }, + lg: { backgroundColor: 'pink' }, + }, + v1_two: { backgroundColor: 'blue' }, + v1_three: { backgroundColor: 'yellow' }, + v2_one: { borderColor: 'red' }, + v2_two: { borderColor: 'blue' }, + v2_three: { borderColor: 'yellow' }, + 'v1_one+v2_three': { borderRadius: 5, borderWidth: 1 }, + 'v1_two+v2_four': { borderRadius: 15, borderWidth: 2 }, + }); + }); + + it('processStyleSheet', () => { + const styleSheet = { + base: { + height: 100, + width: 100, + sm: { height: 50, width: 50 }, + }, + v1_one: { + backgroundColor: 'red', + sm: { backgroundColor: 'orange' }, + md: { backgroundColor: 'black' }, + }, + v1_two: { + backgroundColor: 'blue', + lg: { backgroundColor: 'white' }, + }, + v1_three: { + backgroundColor: 'yellow', + }, + }; + + const media = { + sm: '(width >= 600px)', + md: '(width >= 750px)', + lg: '(width >= 1080px)', + }; + + const result1 = processStyleSheet(styleSheet, media, ['md', 'lg']); + + expect(result1).toEqual({ + base: { height: 100, width: 100 }, + v1_one: { backgroundColor: 'black' }, + v1_two: { backgroundColor: 'white' }, + v1_three: { backgroundColor: 'yellow' }, + }); + + const result2 = processStyleSheet(styleSheet, media, ['sm']); + + expect(result2).toEqual({ + base: { height: 50, width: 50 }, + v1_one: { backgroundColor: 'orange' }, + v1_two: { backgroundColor: 'blue' }, + v1_three: { backgroundColor: 'yellow' }, + }); + }); }); diff --git a/src/tests/utils.ts b/src/tests/utils.ts new file mode 100644 index 0000000..f7db3d3 --- /dev/null +++ b/src/tests/utils.ts @@ -0,0 +1,28 @@ +const aspectRatio = 19.5 / 9; // iPhone 14 + +export function mockDimensions({ + width = 750, + height = width * aspectRatio, + pixelRatio = 1, +}: { + width?: number; + height?: number; + pixelRatio?: number; +}) { + jest.resetModules(); + jest.doMock('react-native/Libraries/Utilities/useWindowDimensions', () => ({ + __esModule: true, + default: jest.fn().mockReturnValue({ width, height }), + })); + jest.doMock('react-native/Libraries/Utilities/PixelRatio', () => ({ + get: () => pixelRatio, + getFontScale: () => 1, + getPixelSizeForLayoutSize: (layoutSize: number) => layoutSize * pixelRatio, + roundToNearestPixel: (layoutSize: number) => + Math.round(layoutSize * pixelRatio) / pixelRatio, + })); +} + +export function reduceStyles(s: any) { + return s.reduce((s1: any, s2: any) => ({ ...s1, ...s2 }), {}); +}