@dr.pogodin/react-themes
Version:
UI theme composition with CSS Modules and React
302 lines (277 loc) • 11.6 kB
JavaScript
import { createContext, use, useMemo } from 'react';
// -----------------------------------------------------------------------------
// TypeScript interfaces & types, constants.
// Note: Support of custom specifity-manipulation classes in TypeScript is too
// cumbersome, thus although it remains a functional feature for pure JavaScript,
// the TypeScript assumes these classes are always "ad", "hoc", and "context".
// NOTE: Keep it as interface, to allow, in theory, consumer to redefine these
// default keys.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
// NOTE: KeyT should be a union of string literals - valid theme keys.
// TODO: Revise, should we change it to type?
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions, @typescript-eslint/consistent-indexed-object-style
// TODO: Revise, should we change it to type?
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
// TODO: Revise, should we change it to type?
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
import { jsx as _jsx } from "react/jsx-runtime";
/** Supported theme composition modes. */
export let COMPOSE = /*#__PURE__*/function (COMPOSE) {
COMPOSE["DEEP"] = "DEEP";
COMPOSE["SOFT"] = "SOFT";
COMPOSE["SWAP"] = "SWAP";
return COMPOSE;
}({});
/** Supported theme priorities. */
export let PRIORITY = /*#__PURE__*/function (PRIORITY) {
PRIORITY["ADHOC_CONTEXT_DEFAULT"] = "ADHOC_CONTEXT_DEFAULT";
PRIORITY["ADHOC_DEFAULT_CONTEXT"] = "ADHOC_DEFAULT_CONTEXT";
return PRIORITY;
}({});
const INVALID_COMPOSE = 'Invalid composition mode';
const Context = /*#__PURE__*/createContext(undefined);
// -----------------------------------------------------------------------------
// Here comes the logic.
/**
* Theme provider defines style contexts. It accepts a single property
* `themes` (`theme` in compatibility modes).
*
* In case of nested context, the context theme from the closest context takes
* the effect on a component. If the context theme for a component is not set in
* the closest context, but it is set in an outer context, the theme from outer
* context will be applied.
*
* @param props.children React content to render in-place of
* <ThemeProvider> component.
*
* @param props.themes The mapping of between themeable component names
* (the first parameter passed into themed() function for such components
* registration), and context themes to apply to them within the context.
*
* @param props.theme Fallback mapping for backward compatibility
* with `react-css-themr` and `react-css-super-themr` libraries.
*/
export const ThemeProvider = ({
children,
themes
}) => {
const contextThemes = use(Context);
// useMemo() ensures we don't generate a new "value" on each render when both
// "contextThemes" and "themes" are defined.
const value = useMemo(() => {
var _ref;
return contextThemes && themes ? {
...contextThemes,
...themes
} : (_ref = contextThemes !== null && contextThemes !== void 0 ? contextThemes : themes) !== null && _ref !== void 0 ? _ref : {};
}, [contextThemes, themes]);
return /*#__PURE__*/_jsx(Context, {
value: value,
children: children
});
};
/**
* Composes two themes.
* @param high High priorty theme.
* @param low Low priority theme.
* @param mode Composition mode.
* @param tag Specifity tag(s).
* @return Composed theme.
*/
function compose(high, low, mode, tag) {
if (high && low) {
switch (mode) {
case COMPOSE.DEEP:
{
const res = {
...low
};
const prefix = Array.isArray(tag) ? `${high[tag[0]] || ''} ${high[tag[1]] || ''}` : high[tag] || '';
for (const key in high) {
if (res[key]) {
res[key] = `${res[key]} ${prefix} ${high[key]}`;
} else res[key] = high[key];
}
return res;
}
case COMPOSE.SOFT:
return {
...low,
...high
};
case COMPOSE.SWAP:
return high;
default:
throw new Error(INVALID_COMPOSE);
}
} else return high !== null && high !== void 0 ? high : low;
}
/**
* @deprecated
*
* Registers a themeable component under given name, and with an optional
* default theme.
* @param componentName Themed component name, which should be used to
* provide its context theme via <ThemeProvider>.
* @param [defaultTheme] Default theme, in form of theme key to
* CSS class name mapping. If you have CSS modules and SCSS loader correctly
* configured, the import `import theme from 'some.theme.scss';` will result
* in `theme` object you can pass here. In some cases, it might be also legit
* to construct theme object in a diffent way.
* @param [options] Additional parameters.
* @param [options.composeAdhocTheme=COMPOSE.DEEP] Composition type for
* _ad hoc_ theme, which is merged into the result of composition of lower
* priority themes. Must be one of COMPOSE values.
* @param [options.composeContextTheme=COMPOSE.DEEP] Composition type
* for context theme into default theme (or vice verca, if opted by
* `themePriority` override). Must be one of COMPOSE values.
* @param [options.themePriority=ADHOC_CONTEXT_DEFAULT] Theme
* priorities. Must be one of PRIORITY values.
* @param [options.mapThemeProps] By default, the themeable
* component
* created by `themed()` does not pass into the original wrapped component any
* properties introduced by this library. It only passes down properties it
* does not recognize, alongside the composed `theme`, and forwarded DOM `ref`.
* In case a different behavior is needed, the property mapper can be
* specified with this option. It should be a function with
* ThemePropsMapper signature, and if present the result from this
* function will be passed down the wrapped component as its props.
* @param [options.contextTag=context] Override of `context` theme
* key.
* @param [options.adhocTag=ad.hoc] Override of `ad.hoc` theme key.
* @param [options.composeTheme] Compatibility compose mode.
* @param [options.mapThemrProps] Compatibility prop mapper.
* @return Themeable component, registered under
* given name.
*/
function themedImpl(componentName, defaultTheme, options = {}) {
const {
adhocTag = 'ad.hoc',
contextTag = 'context',
composeAdhocTheme: oComposeAdhocTheme,
composeContextTheme: oComposeContextTheme,
mapThemeProps: oMapThemeProps,
themePriority: oThemePriority
} = options;
const aTag = adhocTag.split('.');
// TODO: Should we remove this runtime safeguard, assuming by now all
// host projects should use TypeScript, which should prevent the error
// we safeguard against here?
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (aTag.length !== 2 || !aTag[0] || !aTag[1]) {
throw new Error('Invalid adhoc theme tag');
}
return ThemeableComponent => {
const Component = properties => {
var _ref2, _compose;
const {
children,
composeAdhocTheme,
composeContextTheme,
mapThemeProps,
ref,
theme,
themePriority,
...rest
} = properties;
const context = use(Context);
const contextTheme = context === null || context === void 0 ? void 0 : context[componentName];
/* Deduction of applicable theme composition and priority settings. */
const mapper = mapThemeProps !== null && mapThemeProps !== void 0 ? mapThemeProps : oMapThemeProps;
const priority = (_ref2 = themePriority !== null && themePriority !== void 0 ? themePriority : oThemePriority) !== null && _ref2 !== void 0 ? _ref2 : PRIORITY.ADHOC_CONTEXT_DEFAULT;
// TODO: Revise.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/non-nullable-type-assertion-style
const composeAdhoc = composeAdhocTheme || oComposeAdhocTheme || COMPOSE.DEEP;
// TODO: Revise.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/non-nullable-type-assertion-style
const composeContext = composeContextTheme || oComposeContextTheme || COMPOSE.DEEP;
/* Theme composition. */
let res = priority === PRIORITY.ADHOC_DEFAULT_CONTEXT ? compose(defaultTheme, contextTheme, composeContext, contextTag) : compose(contextTheme, defaultTheme, composeContext, contextTag);
res = (_compose = compose(theme, res, composeAdhoc, aTag)) !== null && _compose !== void 0 ? _compose : {};
/* Props deduction. */
const p = mapper ? mapper(properties, res) : {
...rest,
ref,
theme: res
};
/* eslint-disable react/jsx-props-no-spreading */
return /*#__PURE__*/_jsx(ThemeableComponent, {
...p,
children: children
});
/* eslint-enable react/jsx-props-no-spreading */
};
return Component;
};
}
/** @deprecated */
function themed(
// 1st argument.
componentOrComponentName,
// 2nd argument.
componentNameOrDefaultTheme,
// 3rd argument.
defaultThemeOrOptions,
// 4th argument.
options) {
let component;
let componentName;
let defaultTheme;
let ops;
if (typeof componentOrComponentName === 'string') {
// 1st argument: component name.
componentName = componentOrComponentName;
// 2nd argument: default theme.
if (typeof componentNameOrDefaultTheme === 'string') {
throw Error('Second argument is not expected to be a string');
}
defaultTheme = componentNameOrDefaultTheme;
// 3rd argument: options.
ops = defaultThemeOrOptions;
// 4th argument: none.
if (options) throw Error('4th argument is not expected');
} else {
// 1st argument: component.
component = componentOrComponentName;
// 2nd argument: component name.
if (typeof componentNameOrDefaultTheme !== 'string') {
throw Error('Second argument is not a string');
}
componentName = componentNameOrDefaultTheme;
// 3rd argument: default theme.
defaultTheme = defaultThemeOrOptions;
// 4th argument: options.
ops = options;
}
const impl = themedImpl(componentName, defaultTheme, ops);
return component ? impl(component) : impl;
}
/** @deprecated */
export default themed;
/**
* React hook for theme composition.
*/
export function useTheme(componentName, defaultTheme, adHocTheme, options) {
var _compose2;
const {
adhocTag = 'ad.hoc',
contextTag = 'context',
composeAdhocTheme = COMPOSE.DEEP,
composeContextTheme = COMPOSE.DEEP,
themePriority = PRIORITY.ADHOC_CONTEXT_DEFAULT
} = options !== null && options !== void 0 ? options : {};
const aTag = adhocTag.split('.');
// TODO: Should we remove this runtime safeguard, assuming by now all
// host projects should use TypeScript, which should prevent the error
// we safeguard against here?
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (aTag.length !== 2 || !aTag[0] || !aTag[1]) {
throw new Error('Invalid adhoc theme tag');
}
const context = use(Context);
const contextTheme = context === null || context === void 0 ? void 0 : context[componentName];
let res = themePriority === PRIORITY.ADHOC_DEFAULT_CONTEXT ? compose(defaultTheme, contextTheme, composeContextTheme, contextTag) : compose(contextTheme, defaultTheme, composeContextTheme, contextTag);
res = (_compose2 = compose(adHocTheme, res, composeAdhocTheme, aTag)) !== null && _compose2 !== void 0 ? _compose2 : {};
return res;
}
//# sourceMappingURL=index.js.map