UNPKG

@vitus-labs/rocketstyle

Version:

Rocketstyle is ultra powerful and extensible styling system for building React components blazingly fast, easily and make them easily extensible and reusable.

767 lines (744 loc) 30 kB
import { context as context$1, Provider as Provider$1, isEmpty, render, set, get, merge, config, pick, omit, compose } from '@vitus-labs/core'; export { context } from '@vitus-labs/core'; import React, { createContext, useContext, useState, useCallback, useRef, useImperativeHandle, forwardRef, useMemo } from 'react'; import hoistNonReactStatics from 'hoist-non-react-statics'; const MODE_DEFAULT = 'light'; const PSEUDO_KEYS = ['hover', 'active', 'focus', 'pressed']; const PSEUDO_META_KEYS = ['disabled', 'readOnly']; const THEME_MODES = { light: true, dark: true, }; const THEME_MODES_INVERSED = { dark: 'light', light: 'dark', }; const CONFIG_KEYS = [ 'provider', 'consumer', 'DEBUG', 'name', 'component', 'inversed', 'passProps', 'styled', ]; const STYLING_KEYS = ['theme', 'styles']; const STATIC_KEYS = [...STYLING_KEYS, 'compose']; const ALL_RESERVED_KEYS = [ ...Object.keys(THEME_MODES), ...CONFIG_KEYS, ...STATIC_KEYS, 'attrs', ]; const context = createContext({}); const useLocalContext = (consumer) => { const ctx = consumer ? useContext(context) : {}; const result = consumer ? consumer((callback) => callback(ctx)) : {}; return { pseudo: {}, ...result }; }; const LocalProvider = context.Provider; const usePseudoState = ({ onBlur, onFocus, onMouseDown, onMouseEnter, onMouseLeave, onMouseUp, }) => { const [hover, setHover] = useState(false); const [focus, setFocus] = useState(false); const [pressed, setPressed] = useState(false); const handleOnMouseEnter = useCallback((e) => { setHover(true); if (onMouseEnter) onMouseEnter(e); }, [onMouseEnter]); const handleOnMouseLeave = useCallback((e) => { setHover(false); setPressed(false); if (onMouseLeave) onMouseLeave(e); }, [onMouseLeave]); const handleOnMouseDown = useCallback((e) => { setPressed(true); if (onMouseDown) onMouseDown(e); }, [onMouseDown]); const handleOnMouseUp = useCallback((e) => { setPressed(false); if (onMouseUp) onMouseUp(e); }, [onMouseUp]); const handleOnFocus = useCallback((e) => { setFocus(true); if (onFocus) onFocus(e); }, [onFocus]); const handleOnBlur = useCallback((e) => { setFocus(false); if (onBlur) onBlur(e); }, [onBlur]); return { state: { hover, focus, pressed, }, events: { onMouseEnter: handleOnMouseEnter, onMouseLeave: handleOnMouseLeave, onMouseDown: handleOnMouseDown, onMouseUp: handleOnMouseUp, onFocus: handleOnFocus, onBlur: handleOnBlur, }, }; }; const useRocketstyleRef = ({ $rocketstyleRef, ref }) => { const internalRef = useRef(null); useImperativeHandle($rocketstyleRef, () => internalRef.current); useImperativeHandle(ref, () => internalRef.current); return internalRef; }; const Provider = ({ provider = Provider$1, inversed, ...props }) => { const ctx = useContext(context$1); const { theme, mode, provider: RocketstyleProvider, children, } = { ...ctx, ...props, provider }; let newMode = MODE_DEFAULT; if (mode) { newMode = inversed ? THEME_MODES_INVERSED[mode] : mode; } return (React.createElement(RocketstyleProvider, { mode: newMode, isDark: newMode === 'dark', isLight: newMode === 'light', theme: theme, provider: provider }, children)); }; const useThemeAttrs = ({ inversed }) => { const { theme = {}, mode: ctxMode = 'light', isDark: ctxDark, } = useContext(context$1) || {}; const mode = inversed ? THEME_MODES_INVERSED[ctxMode] : ctxMode; const isDark = inversed ? !ctxDark : ctxDark; const isLight = !isDark; return { theme, mode, isDark, isLight }; }; const RocketStyleProviderComponent = (WrappedComponent) => forwardRef(({ onMouseEnter, onMouseLeave, onMouseUp, onMouseDown, onFocus, onBlur, $rocketstate, ...props }, ref) => { // pseudo hook to detect states hover / pressed / focus const pseudo = usePseudoState({ onMouseEnter, onMouseLeave, onMouseUp, onMouseDown, onFocus, onBlur, }); const updatedState = useMemo(() => ({ ...$rocketstate, pseudo: { ...$rocketstate.pseudo, ...pseudo.state }, }), [$rocketstate, pseudo]); return (React.createElement(LocalProvider, { value: updatedState }, React.createElement(WrappedComponent, { ...props, ...pseudo.events, ref: ref, "$rocketstate": updatedState }))); }); class ThemeManager { baseTheme = new WeakMap(); dimensionsThemes = new WeakMap(); modeBaseTheme = { light: new WeakMap(), dark: new WeakMap() }; modeDimensionTheme = { light: new WeakMap(), dark: new WeakMap() }; } /* eslint-disable no-param-reassign */ const removeUndefinedProps = (props) => Object.keys(props).reduce((acc, key) => { const currentValue = props[key]; if (currentValue !== undefined) return { ...acc, [key]: currentValue }; return acc; }, {}); const pickStyledAttrs = (props, keywords) => Object.keys(props).reduce((acc, key) => { if (keywords[key] && props[key]) acc[key] = props[key]; return acc; }, {}); const calculateChainOptions = (options) => (args) => { const result = {}; if (isEmpty(options)) return result; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore return options.reduce((acc, item) => Object.assign(acc, item(...args)), {}); }; const calculateStylingAttrs = ({ useBooleans, multiKeys }) => ({ props, dimensions }) => { const result = {}; // (1) find dimension keys values & initialize // object with possible options Object.keys(dimensions).forEach((item) => { const pickedProp = props[item]; const valueTypes = ['number', 'string']; // if the property is multi key, allow assign array as well if (multiKeys && multiKeys[item] && Array.isArray(pickedProp)) { result[item] = pickedProp; } // assign when it's only a string or number otherwise it's considered // as invalid param else if (valueTypes.includes(typeof pickedProp)) { result[item] = pickedProp; } else { result[item] = undefined; } }); // (2) if booleans are being used let's find the rest if (useBooleans) { const propsKeys = Object.keys(props).reverse(); Object.entries(result).forEach(([key, value]) => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const isMultiKey = multiKeys[key]; // when value in result is not assigned yet if (!value) { let newDimensionValue; const keywords = Object.keys(dimensions[key]); if (isMultiKey) { newDimensionValue = propsKeys.filter((key) => keywords.includes(key)); } else { // reverse props to guarantee the last one will have // a priority over previous ones newDimensionValue = propsKeys.find((key) => { if (keywords.includes(key) && props[key]) return key; return false; }); } result[key] = newDimensionValue; } }); } return result; }; /* eslint-disable no-underscore-dangle */ const rocketStyleHOC = ({ inversed, attrs, priorityAttrs }) => { // -------------------------------------------------- // .attrs(...) // first we need to calculate final props which are // being returned by using `attr` chaining method // -------------------------------------------------- const calculateAttrs = calculateChainOptions(attrs); const calculatePriorityAttrs = calculateChainOptions(priorityAttrs); const Enhanced = (WrappedComponent) => forwardRef((props, ref) => { const { theme, mode, isDark, isLight } = useThemeAttrs({ inversed, }); const callbackParams = [theme, { render, mode, isDark, isLight }]; // -------------------------------------------------- // remove undefined props not to override potential default props // only props with value (e.g. `null`) should override default props // -------------------------------------------------- const filteredProps = removeUndefinedProps(props); const prioritizedAttrs = calculatePriorityAttrs([ filteredProps, ...callbackParams, ]); const finalAttrs = calculateAttrs([ { ...prioritizedAttrs, ...filteredProps, }, ...callbackParams, ]); return (React.createElement(WrappedComponent, { "$rocketstyleRef": ref, ...prioritizedAttrs, ...finalAttrs, ...filteredProps })); }); return Enhanced; }; const createStaticsChainingEnhancers = ({ context, dimensionKeys, func, options, }) => { const keys = [...dimensionKeys, ...STATIC_KEYS]; keys.forEach((item) => { // eslint-disable-next-line no-param-reassign context[item] = (props) => func(options, { [item]: props }); }); }; const createStaticsEnhancers = ({ context, options, }) => { if (!isEmpty(options)) { Object.assign(context, options); } }; /* eslint-disable import/prefer-default-export */ const removeNullableValues = (obj) => Object.entries(obj) .filter(([, v]) => v != null && v !== false) .reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}); // -------------------------------------------------------- // Remove All Empty Values // -------------------------------------------------------- // type RemoveAllEmptyValues = (obj: Record<string, any>) => Record<string, any> // export const removeAllEmptyValues: RemoveAllEmptyValues = (obj) => // Object.entries(obj) // .filter(([, v]) => v != null) // .reduce( // (acc, [k, v]) => ({ // ...acc, // [k]: typeof v === 'object' ? removeAllEmptyValues(v) : v, // }), // {} // ) const isValidKey = (value) => value !== undefined && value !== null && value !== false; const isMultiKey = (value) => { if (typeof value === 'object' && value !== null) return [true, get(value, 'propName')]; return [false, value]; }; const getDimensionsMap = ({ themes, useBooleans }) => { const result = { keysMap: {}, keywords: {}, }; if (isEmpty(themes)) return result; return Object.entries(themes).reduce((accumulator, [key, value]) => { const { keysMap, keywords } = accumulator; keywords[key] = true; Object.entries(value).forEach(([itemKey, itemValue]) => { if (!isValidKey(itemValue)) return; if (useBooleans) { keywords[itemKey] = true; } set(keysMap, [key, itemKey], true); }); return accumulator; }, result); }; const getKeys = (obj) => Object.keys(obj); const getValues = (obj) => Object.values(obj); const getDimensionsValues = (obj) => getValues(obj).map((item) => { if (typeof item === 'object') { return item.propName; } return item; }); const getMultipleDimensions = (obj) => getValues(obj).reduce((accumulator, value) => { if (typeof value === 'object') { // eslint-disable-next-line no-param-reassign if (value.multi === true) accumulator[value.propName] = true; } return accumulator; }, {}); /* eslint-disable no-param-reassign */ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // -------------------------------------------------------- // Theme Mode Callback // -------------------------------------------------------- const themeModeCallback = (light, dark) => (mode) => { if (!mode || mode === 'light') return light; return dark; }; const isModeCallback = (value) => typeof value === 'function' && //@ts-ignore value.toString() === themeModeCallback().toString(); const getThemeFromChain = (options, theme) => { const result = {}; if (!options || isEmpty(options)) return result; return options.reduce((acc, item) => merge(acc, item(theme, themeModeCallback, config.css)), result); }; const getDimensionThemes = (theme, options) => { const result = {}; if (isEmpty(options.dimensions)) return result; return Object.entries(options.dimensions).reduce((acc, [key, value]) => { const [, dimension] = isMultiKey(value); const helper = options[key]; if (Array.isArray(helper) && helper.length > 0) { const finalDimensionThemes = getThemeFromChain(helper, theme); // eslint-disable-next-line no-param-reassign acc[dimension] = removeNullableValues(finalDimensionThemes); } return acc; }, result); }; const getTheme = ({ rocketstate, themes, baseTheme }) => { // generate final theme which will be passed to styled component let finalTheme = { ...baseTheme }; Object.entries(rocketstate).forEach(([key, value]) => { const keyTheme = themes[key]; if (Array.isArray(value)) { value.forEach((item) => { finalTheme = merge({}, finalTheme, keyTheme[item]); }); } else { finalTheme = merge({}, finalTheme, keyTheme[value]); } }); return finalTheme; }; const getThemeByMode = (object, mode) => Object.keys(object).reduce((acc, key) => { const value = object[key]; if (typeof value === 'object' && value !== null) { acc[key] = getThemeByMode(value, mode); } else if (isModeCallback(value)) { acc[key] = value(mode); } else { acc[key] = value; } return acc; }, {}); const chainOptions = (opts, defaultOpts = []) => { const result = [...defaultOpts]; if (typeof opts === 'function') result.push(opts); else if (typeof opts === 'object') result.push(() => opts); return result; }; const chainOrOptions = (keys, opts, defaultOpts) => keys.reduce((acc, item) => ({ ...acc, [item]: opts[item] || defaultOpts[item] }), {}); const chainReservedKeyOptions = (keys, opts, defaultOpts) => keys.reduce((acc, item) => ({ ...acc, [item]: chainOptions(opts[item], defaultOpts[item]), }), {}); const calculateHocsFuncs = (options = {}) => Object.values(options) .filter((item) => typeof item === 'function') .reverse(); /* eslint-disable import/prefer-default-export */ const calculateStyles = (styles) => { if (!styles) return []; return styles.map((item) => item(config.css)); }; /* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable no-underscore-dangle */ const cloneAndEnhance = (defaultOpts, opts) => // @ts-ignore rocketComponent({ ...defaultOpts, attrs: chainOptions(opts.attrs, defaultOpts.attrs), filterAttrs: [ ...(defaultOpts.filterAttrs ?? []), ...(opts.filterAttrs ?? []), ], priorityAttrs: chainOptions(opts.priorityAttrs, defaultOpts.priorityAttrs), statics: { ...defaultOpts.statics, ...opts.statics }, compose: { ...defaultOpts.compose, ...opts.compose }, ...chainOrOptions(CONFIG_KEYS, opts, defaultOpts), ...chainReservedKeyOptions([...defaultOpts.dimensionKeys, ...STYLING_KEYS], opts, defaultOpts), }); // -------------------------------------------------------- // styleComponent // helper function which allows function chaining // always returns a valid React component with static functions // assigned, so it can be even rendered as a valid component // or styles can be extended via its statics // -------------------------------------------------------- // @ts-ignore const rocketComponent = (options) => { const { component, styles, DEBUG } = options; const { styled } = config; const _calculateStylingAttrs = calculateStylingAttrs({ multiKeys: options.multiKeys, useBooleans: options.useBooleans, }); const componentName = options.name ?? options.component.displayName ?? options.component.name; // create styled component with all options.styles if available const STYLED_COMPONENT = component.IS_ROCKETSTYLE ?? options.styled !== true ? component : styled(component) ` ${calculateStyles(styles)}; `; // -------------------------------------------------------- // COMPONENT - Final component to be rendered // -------------------------------------------------------- const RenderComponent = options.provider ? RocketStyleProviderComponent(STYLED_COMPONENT) : STYLED_COMPONENT; // -------------------------------------------------------- // THEME - Cached & Calculated theme(s) // -------------------------------------------------------- const ThemeManager$1 = new ThemeManager(); // -------------------------------------------------------- // COMPOSE - high-order components // -------------------------------------------------------- const hocsFuncs = [ rocketStyleHOC(options), ...calculateHocsFuncs(options.compose), ]; // -------------------------------------------------------- // ENHANCED COMPONENT (returned component) // -------------------------------------------------------- // .attrs() chaining option is calculated in HOC and passed as props already // @ts-ignore // eslint-disable-next-line react/display-name const EnhancedComponent = forwardRef(({ $rocketstyleRef, // it's forwarded from HOC which is always on top of all hocs ...props }, ref) => { // -------------------------------------------------- // handle refs // (1) one is passed from inner HOC - $rocketstyleRef // (2) second one is used to be used directly (e.g. inside hocs) // -------------------------------------------------- const internalRef = useRocketstyleRef({ $rocketstyleRef, ref }); // -------------------------------------------------- // hover - focus - pressed state passed via context from parent component // -------------------------------------------------- const localCtx = useLocalContext(options.consumer); // -------------------------------------------------- // general theme and theme mode dark / light passed in context // -------------------------------------------------- const { theme, mode } = useThemeAttrs(options); // -------------------------------------------------- // calculate themes for all defined styling dimensions // .theme(...) + defined dimensions like .states(...), .sizes(...), etc. // -------------------------------------------------- // -------------------------------------------------- // BASE / DEFAULT THEME Object // -------------------------------------------------- const baseTheme = useMemo(() => { const helper = ThemeManager$1.baseTheme; if (!helper.has(theme)) { helper.set(theme, getThemeFromChain(options.theme, theme)); } return helper.get(theme); }, // recalculate this only when theme mode changes dark / light [theme]); // -------------------------------------------------- // DIMENSION(S) THEMES Object // -------------------------------------------------- const themes = useMemo(() => { const helper = ThemeManager$1.dimensionsThemes; if (!helper.has(theme)) { helper.set(theme, getDimensionThemes(theme, options)); } return helper.get(theme); }, // recalculate this only when theme object changes [theme]); // -------------------------------------------------- // BASE / DEFAULT MODE THEME Object // -------------------------------------------------- const currentModeBaseTheme = useMemo(() => { const helper = ThemeManager$1.modeBaseTheme[mode]; if (!helper.has(baseTheme)) { helper.set(baseTheme, getThemeByMode(baseTheme, mode)); } return helper.get(baseTheme); }, // recalculate this only when theme mode changes dark / light [mode, baseTheme]); // -------------------------------------------------- // DIMENSION(S) MODE THEMES Object // -------------------------------------------------- const currentModeThemes = useMemo(() => { const helper = ThemeManager$1.modeDimensionTheme[mode]; if (!helper.has(themes)) { helper.set(themes, getThemeByMode(themes, mode)); } return helper.get(themes); }, // recalculate this only when theme mode changes dark / light [mode, themes]); // -------------------------------------------------- // calculate reserved Keys defined in dimensions as styling keys // there is no need to calculate this each time - keys are based on // dimensions definitions // -------------------------------------------------- const { keysMap: dimensions, keywords: reservedPropNames } = useMemo(() => getDimensionsMap({ themes, useBooleans: options.useBooleans, }), [themes]); const RESERVED_STYLING_PROPS_KEYS = useMemo(() => Object.keys(reservedPropNames), [reservedPropNames]); // -------------------------------------------------- // get final props which are (latest has the highest priority): // (1) merged styling from context, // (2) `attrs` chaining method, and from // (3) passing them directly to component // -------------------------------------------------- const { pseudo, ...mergeProps } = { ...localCtx, ...props, }; // -------------------------------------------------- // pseudo rocket state // calculate final component pseudo state including pseudo state // from props and override by pseudo props from context // -------------------------------------------------- const pseudoRocketstate = { ...pseudo, ...pick(props, [...PSEUDO_KEYS, ...PSEUDO_META_KEYS]), }; // -------------------------------------------------- // rocketstate // calculate final component state including pseudo state // passed as $rocketstate prop // -------------------------------------------------- const rocketstate = _calculateStylingAttrs({ props: pickStyledAttrs(mergeProps, reservedPropNames), dimensions, }); const finalRocketstate = { ...rocketstate, pseudo: pseudoRocketstate }; // -------------------------------------------------- // rocketstyle // calculated (based on styling props) final theme which will be passed // to our styled component // passed as $rocketstyle prop // -------------------------------------------------- const rocketstyle = getTheme({ rocketstate, themes: currentModeThemes, baseTheme: currentModeBaseTheme, }); // -------------------------------------------------- // final props // final props passed to WrappedComponent // excluding: styling props // including: $rocketstyle, $rocketstate // -------------------------------------------------- const finalProps = { // this removes styling state from props and passes its state // under rocketstate key only ...omit(mergeProps, [ ...RESERVED_STYLING_PROPS_KEYS, ...PSEUDO_KEYS, ...options.filterAttrs, ]), // if enforced to pass styling props, we pass them directly ...(options.passProps ? pick(mergeProps, options.passProps) : {}), ref: ref ?? $rocketstyleRef ? internalRef : undefined, // state props passed to styled component only, therefore the `$` symbol $rocketstyle: rocketstyle, $rocketstate: finalRocketstate, }; if (DEBUG && process.env.NODE_ENV !== 'production') { console.log('[Rocketstyle] Debug mode enabled'); console.log(`component ${componentName}`); console.log(finalProps); } // all the development stuff injected if (process.env.NODE_ENV !== 'production') { finalProps['data-rocketstyle'] = componentName; } return React.createElement(RenderComponent, { ...finalProps }); }); // ------------------------------------------------------ // This will hoist and generate dynamically next static methods // for all dimensions available in configuration // ------------------------------------------------------ const RocketComponent = compose(...hocsFuncs)(EnhancedComponent); RocketComponent.IS_ROCKETSTYLE = true; RocketComponent.displayName = componentName; hoistNonReactStatics(RocketComponent, options.component); // ------------------------------------------------------ // enhance for chaining methods // ------------------------------------------------------ createStaticsChainingEnhancers({ context: RocketComponent, dimensionKeys: options.dimensionKeys, func: cloneAndEnhance, options, }); // ------------------------------------------------------ RocketComponent.IS_ROCKETSTYLE = true; RocketComponent.displayName = componentName; RocketComponent.meta = {}; // ------------------------------------------------------ // ------------------------------------------------------ // enhance for statics // ------------------------------------------------------ createStaticsEnhancers({ context: RocketComponent.meta, options: options.statics, }); // @ts-ignore RocketComponent.attrs = (attrs, { priority, filter } = {}) => { const result = {}; if (filter) { result.filterAttrs = filter; } if (priority) { result.priorityAttrs = attrs; return cloneAndEnhance(options, result); } result.attrs = attrs; return cloneAndEnhance(options, result); }; // @ts-ignore RocketComponent.config = (opts = {}) => { const result = pick(opts, CONFIG_KEYS); // @ts-ignore return cloneAndEnhance(options, result); }; RocketComponent.statics = (opts) => // @ts-ignore cloneAndEnhance(options, { statics: opts }); RocketComponent.getStaticDimensions = (theme) => { const themes = getDimensionThemes(theme, options); const { keysMap, keywords } = getDimensionsMap({ themes, useBooleans: options.useBooleans, }); return { dimensions: keysMap, keywords, useBooleans: options.useBooleans, multiKeys: options.multiKeys, }; }; RocketComponent.getDefaultAttrs = (props, theme, mode) => calculateChainOptions(options.attrs)([ props, theme, { render, mode, isDark: mode === 'light', isLight: mode === 'dark', }, ]); return RocketComponent; }; const DEFAULT_DIMENSIONS = { states: 'state', sizes: 'size', variants: 'variant', multiple: { propName: 'multiple', multi: true, }, }; // @ts-nocheck const rocketstyle = ({ dimensions = DEFAULT_DIMENSIONS, useBooleans = true } = {}) => ({ name, component }) => { // -------------------------------------------------------- // handle ERRORS in development mode // -------------------------------------------------------- if (process.env.NODE_ENV !== 'production') { const errors = {}; if (!component) { errors.component = 'Parameter `component` is missing in params!'; } if (!name) { errors.name = 'Parameter `name` is missing in params!'; } if (isEmpty(dimensions)) { errors.dimensions = 'Parameter `dimensions` is missing in params!'; } else { const definedDimensions = getKeys(dimensions); const invalidDimension = ALL_RESERVED_KEYS.some((item) => definedDimensions.includes(item)); if (invalidDimension) { errors.invalidDimensions = `Some of your \`dimensions\` is invalid and uses reserved static keys which are ${DEFAULT_DIMENSIONS.toString()}`; } } if (!isEmpty(errors)) { throw Error(JSON.stringify(errors)); } } return rocketComponent({ name, component, useBooleans, dimensions, dimensionKeys: getKeys(dimensions), dimensionValues: getDimensionsValues(dimensions), multiKeys: getMultipleDimensions(dimensions), styled: true, }); }; const isRocketComponent = (component) => { if (component && typeof component === 'object' && component !== null && Object.prototype.hasOwnProperty.call(component, 'IS_ROCKETSTYLE')) { return true; } return false; }; export { Provider, rocketstyle as default, isRocketComponent, rocketstyle }; //# sourceMappingURL=index.js.map