tw-themes
Version:
powerful tailwind color themes (dynamically selectable at run-time)
526 lines (426 loc) • 22.6 kB
JavaScript
import twColors from 'tailwindcss/colors'; // ... peerDependency: tailwindcss
import check from './util/check.js';
import {isArray,
isBoolean,
isPlainObject,
isString} from './util/typeCheck';
//***
//*** NOTE: see docs for complete and thorough descriptions!
//***
// the prefix of all CSS Variables - 'twt': tw-themes
const prefix = 'twt';
// the standard shades supported by tailwindcss colors out-of-the-box
const shades = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'];
// parameter indicator to apply run-time defaults
const runtimeDefault = 'runtimeDefault';
//***
//*** + initTwThemes(schema, themes, [initialThemeName], [initialInvertShade]): TwThemes
//***
export default function initTwThemes(schema, themes, initialThemeName=runtimeDefault, initialInvertShade=false) {
// carve out our "crucial" TwThemes object state
// NOTE: all TwThemes object state begins with underbar (as a convention)
const _schema = schema; // active schema ... alias to initTwThemes() param, making underbar state consistent
const _themes = themes; // active themes ... ditto
let _activeThemeName; // active themeName ... maintained by activateTheme()
let _activeInvertShade; // active invertShade ... ditto
// provide basic parameter validation
// ... additional validation is applied when setup our value-added structures (below)
const checkParam = check.prefix('initTwThemes() parameter violation: ');
// ... schema
checkParam(schema, 'schema is required');
checkParam(isArray(schema), 'schema must be an array of strings (context color names)');
// ... themes
checkParam(themes, 'themes is required');
checkParam(isPlainObject(themes), 'themes must be a JSON structure');
// setup our value-added schema structure
// ... applying additional validation
// EX:
// const _schemaStruct = {
// contextColor1: {
// multiColorViaShades: true, // can be supplied by: 'red'
// // singleColor: false, // OPPOSITE (not used)
// },
// contextColor2: {
// multiColorViaShades: false, // can be supplied by: 'red-700', 'white', HANDLE '#ff52c3' (NOTE: tailwinds 'black'/'white' is inconsistent as it is a single color)
// // singleColor: true, // OPPOSITE (not used)
// },
// ... snip snip
// };
const _totalContextColors = schema.length;
const _schemaStruct = schema.reduce( (accum, contextColor) => {
if (isString(contextColor)) { // a non-shaded single-color contextColor
checkParam(!accum[contextColor], `schema contains duplicate contextColor: ${contextColor}`);
accum[contextColor] = {
multiColorViaShades: false,
};
}
else if (isArray(contextColor)) { // a multi-color shaded contextColor
checkParam(contextColor.length===1, `schema element for shaded contextColor must be a single element "inner" array, NOT: ${contextColor.length} element(s) ... EX: ['primary', 'secondary', ['error']]`);
checkParam(isString(contextColor[0]), `schema element for shaded contextColor must be a single element "inner" array of type string, NOT: ${contextColor[0]} ... EX: ['primary', 'secondary', ['error']]`);
checkParam(!accum[contextColor[0]], `schema contains duplicate contextColor: ${contextColor[0]}`);
accum[contextColor[0]] = {
multiColorViaShades: true,
};
}
else {
checkParam(false, `invalid schema element: ${contextColor} ... expecting a string -or- a string wrapped in an "inner" array ... EX: ['primary', 'secondary', ['error']]`);
}
return accum;
}, {}); // ... initial value
// insure our schema has at least one context color
checkParam(schema.length > 0, `schema must contain at least one context color`);
// setup our value-added themes structure
// ... applying additional validation
// EX:
// const _themesArr = [
// {
// themeName: 'emerald', // ADDED: field added by US
// `clientProps`: `clientValues`,
// contextColors: {
// primary: '#046307', // EX 1: a single custom color via CSS
// secondary: 'coolGray', // EX 2: multiple colors via: a shaded tailwind color
// error: 'red-500', // EX 3: a single color via a tailwind color shade
// },
// resolvedRealColors: { // ADDED: field/structure added by US
// primary: { // EX 1: a single custom color via CSS
// customColor: "#ff52c3",
// },
// secondary: { // EX 2: multiple colors via: a shaded tailwind color
// twColorName: "red",
// },
// error: { // EX 3: a single color via a tailwind color shade
// twColorName: "red",
// twColorShade: "200", // ... ONLY supplied for a single shaded color (for black/white use "dummy-black-white" because it is NOT referenced in getRealTWColor())
// },
// },
// },
// {
// ... more themes
// },
// ];
const _themesArr = Object.entries(themes).map( ([themeName, themeStruct]) => {
const checkWithTheme = checkParam.prefix(`theme: '${themeName}' `);
checkWithTheme(isPlainObject(themeStruct), `must reference a JSON structure`);
themeStruct.themeName = themeName; // ADDED: field added by US
checkWithTheme(themeStruct.contextColors, `must contain a contextColors property`);
checkWithTheme(isPlainObject(themeStruct.contextColors), `contextColors field must reference a JSON structure`);
const resolvedRealColors = themeStruct.resolvedRealColors = {}; // ADDED: field/structure added by US
// validate each contextColor entry in the contextColors structure
let numContextColorsDefined = 0;
Object.entries(themeStruct.contextColors).forEach( ([contextColor, realColor]) => {
const checkWithContextColor = checkWithTheme.prefix(`contextColor: '${contextColor}' `);
const realColorStruct = resolvedRealColors[contextColor] = {};
// verify realColor is a string
checkWithContextColor(isString(realColor), `must reference a string-based realColor`);
const checkWithRealColor = checkWithContextColor.prefix(`realColor: '${realColor}' `);
realColor = realColor.trim();
// verify contextColor is defined in the schema
checkWithRealColor(_schemaStruct[contextColor], `the contextColor is NOT defined in the schema`);
numContextColorsDefined++;
// interpret the realColor string (various syntaxes: 'red', 'red-400', '#ffd3b9')
const [colorName, shade, tooManyDashes] = realColor.split('-');
const checkWithInvalidRealColor = checkWithRealColor.prefix(`invalid realColor: `);
// ... EX: 'red-500-ouch': invalid realColor - too many dashes
if (tooManyDashes) {
checkWithInvalidRealColor(false, `only a single suffix dash is supported (for a color shade)`);
}
// ... EX: 'red-400': a specific shade of a tailwind color
else if (shade) {
// verify the schema (context color) matches the shade/non-shade indicator (of this real color)
checkWithInvalidRealColor(_schemaStruct[contextColor].multiColorViaShades===false, `references a single tailwind shaded color (with a dash -), but the schema requires a multi-color shaded context color (without a dash)`);
// resolve the twColor/twColorShade to use
const twColor = twColors[colorName];
checkWithInvalidRealColor(twColor, `references an invalid tailwind color ... ${colorName} does NOT exist`);
const twColorShade = twColor[shade];
checkWithInvalidRealColor(twColorShade, `references an invalid tailwind color shade ... ${shade} does NOT exist`);
// VALID: retain our run-time setting
realColorStruct.twColorName = colorName;
realColorStruct.twColorShade = shade;
}
// ... EX: 'red'/'black'/'#ff52c3'
else if (colorName) {
// resolve the twColor to use
const twColor = twColors[colorName];
// ... EX: '#ff52c3': NOT a TW color
if (!twColor) {
// verify the schema (context color) matches the shade/non-shade indicator (of this real color)
checkWithInvalidRealColor(_schemaStruct[contextColor].multiColorViaShades===false, `references a single CSS color: '${colorName}', but the schema requires a multi-color shaded context color (which can only be supplied by a tailwind color)`);
// ASSUME a VALID CSS Color, and use as-is
realColorStruct.customColor = colorName;
}
// ... EX: 'black': a special case 'black'/'white' a single color
else if (isString(twColor)) {
// verify the schema (context color) matches the shade/non-shade indicator (of this real color)
checkWithInvalidRealColor(_schemaStruct[contextColor].multiColorViaShades===false, `references a single tailwind color (black/white), but the schema requires a multi-color shaded context color`);
// VALID: retain our run-time setting
realColorStruct.twColorName = colorName;
realColorStruct.twColorShade = 'dummy-black-white'; // triggers single color BUT NOT referenced in getRealTWColor() (because of special black/white logic)
}
// ... EX: 'red': a shaded tailwind color (or special case 'black'/'white' a single color)
else {
// verify the schema (context color) matches the shade/non-shade indicator (of this real color)
checkWithInvalidRealColor(_schemaStruct[contextColor].multiColorViaShades===true, `references multiple tailwind shaded colors (without a dash -), but the schema requires a single-color non-shaded context color (with a dash)`);
// VALID: retain our run-time setting
realColorStruct.twColorName = colorName;
}
}
// ... EX: ''
else {
checkWithInvalidRealColor(false, `the realColor has NO content`);
}
}); // ... end of contextColors iteration
// insure this contextColors section has definitions for all schema colors
if (numContextColorsDefined !== schema.length) {
// NOTE: we have already handled contextColor entries that are NOT defined in the schema (above)
// ... only thing left is missing contextColor entries (from the schema)
const themeContextColors = Object.keys(themeStruct.contextColors);
const missingContextColors = schema.filter( e => !themeContextColors.includes(e) );
checkWithTheme(false, `theme is missing the following context color definitions: ${missingContextColors}`);
}
// add to our value-added themes array (via map())
return themeStruct;
}); // ... end of themes iteration
// insure our themes have at least one theme
checkParam(_themesArr.length > 0, `themes must contain at least one theme`);
// validate initialThemeName parameter, applying run-time defaults (as needed)
if (initialThemeName === runtimeDefault) {
initialThemeName = _themesArr[0].themeName; // ... run-time default: first theme
}
checkParam(isString(initialThemeName), `initialThemeName (when supplied) must be a string`);
checkParam(_themes[initialThemeName], `supplied initialThemeName: '${initialThemeName}' IS NOT defined in themes`);
// validate initialInvertShade parameter
checkParam(isBoolean(initialInvertShade), `initialInvertShade (when supplied) must be a boolean`);
//***
//*** INTERNAL Helper: interpret the real color of a single tailwind color reference (relative to our _activeInvertShade state)
//*** - getRealTWColor(twColorName, [shade]) realColor
//***
function getRealTWColor(twColorName, shade='500') {
// NOTE: Due to the pre-checks done in initTwThemes(),
// there is NO need to check `twColors` error conditions
// ... this is a tightly controlled internal helper
// resolve the twColor
// ... will be one of the following:
// - '#000'/'#fff': the special TW single colors ('black'/'white' IS: '#000'/'#fff')
// - a TW JSON object wrapper of shaded colors: { '50': '#f9fafb', '100': '#f3f4f6', ... }
// N/A - undefined: a CSS color reference <<< NOT in the context of our usage (see NOTE above)
const twColor = twColors[twColorName];
// the special TW single colors ('black'/'white' IS: '#000'/'#fff')
// ... when used, we ignore shade
const twSingleColor = isString(twColor) ? twColor : undefined;
// when in `_activeInvertShade` state: invert the color shades
if (_activeInvertShade) {
// ... invert TW's single black/white color
if (twSingleColor) {
const realColor = invertTWSingleColors[twSingleColor];
if (!realColor) {
// UNEXPECTED: expecting to invert black/white, but there is something else in the mix
// ... just PUNT and return the un-inverted single color
console.warn(`tw-themes: UNEXPECTED CONDITION in getRealTWColor(twColorName: '${twColorName}', shade: '${shade}) when inverting color ` +
`... expecting to invert black/white, but there is apparently an additional tailwind single color in the mix ` +
'... PUNT and use the un-inverted single color');
return twSingleColor;
}
return realColor;
}
else {
// ... invert TW's shaded color
const realColor = twColor[ invertTWColorShades[shade] ];
return realColor;
}
}
// when NOT in `_activeInvertShade` state: use the color as-is
else {
const realColor = twSingleColor || twColor[shade];
return realColor;
}
}
//***
//*** register our app on-load to activate our initialThemeName/initialInvertShade
//***
// ... conditionally, when executing in browser container
// can also run in the Node build process via tailwind.config.js
if (typeof window !== 'undefined') {
// use `window.addEventListener()` vs. `window.onload = () => {`
// ... more robust, allowing client app logic to register their own window.onload events
window.addEventListener('load', (e) => {
activateTheme({themeName: initialThemeName, invertShade: initialInvertShade});
});
}
//***
//*** + activateTheme({[themeName], [invertShade]}): [activeThemeName, activeInvertShade]
//***
function activateTheme(namedParams={}) {
// validate parameters, applying run-time defaults (as needed)
const checkParam = check.prefix('activateTheme() parameter violation: ');
checkParam(isPlainObject(namedParams), `uses named parameters (check the API)`);
let {themeName=runtimeDefault, invertShade=runtimeDefault, ...unknownNamedArgs} = namedParams;
// ... themeName
if (themeName === runtimeDefault) { // ... apply run-time default: currently active themeName
themeName = _activeThemeName;
}
checkParam(isString(themeName), `themeName (when supplied) must be a string`);
checkParam(_themes[themeName], `supplied themeName: '${themeName}' IS NOT defined in themes`);
// ... invertShade
if (invertShade === runtimeDefault) { // ... apply run-time default: currently active invertShade setting
invertShade = _activeInvertShade;
}
checkParam(isBoolean(invertShade), `invertShade (when supplied) must be a boolean`);
// ... unrecognized named parameter
const unknownArgKeys = Object.keys(unknownNamedArgs);
checkParam(unknownArgKeys.length === 0, `unrecognized named parameter(s): ${unknownArgKeys}`);
// ... unrecognized positional parameter
// NOTE: when defaulting entire struct, arguments.length is 0
checkParam(arguments.length <= 1, `unrecognized positional parameters (only named parameters may be specified) ... ${arguments.length} positional parameters were found`);
// NO-OP if NO change has been requested
if (themeName === _activeThemeName &&
invertShade === _activeInvertShade) {
return [_activeThemeName, _activeInvertShade];
}
// retain the latest state
_activeThemeName = themeName;
_activeInvertShade = invertShade; // IMPORTANT: set this prior to getRealTWColor() invocation (uses this setting)
// process the request:
// set the context-based CSS Vars to the real colors defined by the active theme
const theme = _themes[themeName]; // ... our current active theme
const style = document.body.style; // ... our document's <body> in-line style (used to set CSS Vars)
// ... apply changes to our CSS VARS
Object.entries(theme.resolvedRealColors).forEach( ([contextColorName, realColorStruct]) => {
// for a "real" CSS customColor: use as-is: a non-shaded single color where inversion is NOT supported
if (realColorStruct.customColor) {
style.setProperty(`--${prefix}-${contextColorName}`, realColorStruct.customColor);
}
// for a "real" tailwind single color (where shade is provided): promote that single color SUPPORTING inversion
else if (realColorStruct.twColorShade) {
style.setProperty(`--${prefix}-${contextColorName}`, getRealTWColor(realColorStruct.twColorName, realColorStruct.twColorShade));
}
// for a "real" tailwind multi-color color (where NO shade is provided): promote multi-colors via shade SUPPORTING inversion
else {
// ... the DEFAULT ('500' shade)
style.setProperty(`--${prefix}-${contextColorName}`, getRealTWColor(realColorStruct.twColorName));
// ... all shades
shades.forEach( (shade) => {
style.setProperty(`--${prefix}-${contextColorName}-${shade}`, getRealTWColor(realColorStruct.twColorName, shade));
});
}
});
// that's all folks :-)
return [_activeThemeName, _activeInvertShade];
}
//***
//*** + activateNextTheme(): activeThemeName
//***
function activateNextTheme() {
const activeThemeIndx = _themesArr.findIndex( (theme) => theme.themeName === _activeThemeName );
// advance to the next theme (wrapping at end)
const nextThemeIndx = (activeThemeIndx+1) % _themesArr.length;
const nextThemeName = _themesArr[nextThemeIndx].themeName;
activateTheme({themeName: nextThemeName});
// beam me up Scotty :-)
return _activeThemeName; // ... our state has now been updated (via activateTheme())
}
//***
//*** + activatePriorTheme(): activeThemeName
//***
function activatePriorTheme() {
const activeThemeIndx = _themesArr.findIndex( (theme) => theme.themeName === _activeThemeName );
// advance to the prior theme (wrapping at start)
const priorThemeIndx = activeThemeIndx===0 ? _themesArr.length-1 : activeThemeIndx-1;
const priorThemeName = _themesArr[priorThemeIndx].themeName;
activateTheme({themeName: priorThemeName});
// beam me up Scotty :-)
return _activeThemeName; // ... our state has now been updated (via activateTheme())
}
//***
//*** + toggleInvertShade(): activeInvertShade
//***
function toggleInvertShade() {
activateTheme({invertShade: !_activeInvertShade});
// that was easy :-)
return _activeInvertShade; // ... our state has now been updated (via activateTheme())
}
//***
//*** + getThemes(): Theme[]
//***
function getThemes() {
// dohhh ...
return _themesArr;
}
//***
//*** + getActiveThemeName(): activeThemeName
//***
function getActiveThemeName() {
// this is too easy ...
// ... conditional resolves app-initialization window.onload race condition
return _activeThemeName===undefined ? initialThemeName : _activeThemeName;
}
//***
//*** + getActiveInvertShade(): activeInvertShade
//***
function getActiveInvertShade() {
// need I say more ...
// ... conditional resolves app-initialization window.onload race condition
return _activeInvertShade===undefined ? initialInvertShade : _activeInvertShade;
}
//***
//*** + colorConfig(): TwColors
//***
function colorConfig() {
// generate our JSON color structure ... see: TwColors in docs
const colors = {};
_schema.forEach( (contextColorRef) => {
const contextColorName = isString(contextColorRef) ? contextColorRef : contextColorRef[0];
const contextColorStruct = _schemaStruct[contextColorName];
// for multi-color shaded contextColor ... inject all supported shades
if (contextColorStruct.multiColorViaShades) {
const colorNode = colors[contextColorName] = {
DEFAULT: `var(--${prefix}-${contextColorName})`, // color default
};
shades.forEach( (shade) => { // color shades
colorNode[shade] = `var(--${prefix}-${contextColorName}-${shade})`;
});
}
// for a single-color contextColor ... inject a single color
else {
colors[contextColorName] = `var(--${prefix}-${contextColorName})`; // the single color
}
});
// that's all folks :-)
return colors;
}
//***
//*** end of initTwThemes()
//***
// that's all folks :-)
// ... return our TwThemes object, from which from which all remaining functionality is promoted :-)
return {
activateTheme,
activateNextTheme,
activatePriorTheme,
toggleInvertShade,
getThemes,
getActiveThemeName,
getActiveInvertShade,
colorConfig,
};
} // ... end of: initTwThemes()
//***
//*** helper tables
//***
// color inverter table for tailwind color shades
const invertTWColorShades = {
"50": "900", // use a "close" entry (we don't have a corresponding entry for 50)
"100": "900",
"200": "800",
"300": "700",
"400": "600",
"500": "500",
"600": "400",
"700": "500",
"800": "600",
"900": "100",
};
// color inverter table for tailwind's special single colors ('black'/'white' IS: '#000'/'#fff')
const invertTWSingleColors = {
[twColors.black]: twColors.white,
[twColors.white]: twColors.black,
}