apphouse
Version:
Component library for React that uses observable state management and theme-able components.
739 lines (653 loc) • 20.2 kB
text/typescript
import { values } from '../utils/obj/values';
import { uuidv4 } from '@firebase/util';
import { makeAutoObservable } from 'mobx';
import {
rbgaStringToHex,
toApphouseColors,
toColorsObjectFlat
} from './utils/color.utils';
import { toApphouseStyles } from './utils/styles.utils';
import {
getColorTokensLookupReferenceFromPalettes,
getLookupTable,
getTokenListId,
hasColorReference,
toApphouseTokens,
toTokensObject
} from './utils/tokens.utils';
import { Color, getDefaultColors } from './Color';
import { Palette } from './Palette';
import { Style } from './Style';
import { getValueFromReferenceString } from './utils/theme.utils';
import { Token } from './Token';
import { ApphouseTheme, ThemeFull } from '../styles/defaults/themes.interface';
import { BaseThemeSettings } from './defaults.tokens';
import { ApphousePaletteModeOptions } from '../constants/constants';
import { ThemeUpdatesTracker } from './ThemeUpdatesTracker';
import { objThemeToTheme, PaletteUtils } from './utils/theme.conversion.utils';
import { ApphouseThemeTokens } from '../styles/defaults/app.token.values';
import { ColorDefinition } from './utils/color.interface';
import {
BaseComponentTypeOption,
CssPropertyStyle,
StyleType,
TagPreviewOption
} from './style.interface';
import {
TOKEN_KEY_SEPARATOR,
TokenType,
TokenTypeOption,
tokenTypes
} from './token.interface';
import { PaletteType, ThemeModeType } from './palette.interface';
type LookupTable = { [id: string]: string };
const presentationModeOptions = [
'styles',
'tokens',
'colors',
'palette',
'all',
...tokenTypes
];
export type PresentationModeOption =
| (typeof presentationModeOptions)[number]
| string;
export class Theme {
title: string;
id: string;
palette: Record<string, Palette>;
tokens: Record<string, Token>;
styles: Record<string, Style>;
sync: ThemeUpdatesTracker;
presentationMode?: PresentationModeOption;
constructor(prop: ThemeFull) {
this.title = prop.title || 'Untitled Design System';
this.id = prop.id || uuidv4();
const theme = objThemeToTheme(prop);
this.palette = theme.palette || {};
this.tokens = theme.tokens || {};
this.styles = theme.styles || {};
this.sync = new ThemeUpdatesTracker();
makeAutoObservable(this);
}
get paletteColorsIds(): string[] {
return Object.keys(this.palette).map((key) => {
return this.palette[key].id;
});
}
/**
* If the theme has any palette with mode === "theme", the theme will have
* themed styles. hasThemedStyles is helpful for determining how to extract
* the values from the styles when importing / exporting. If the theme
* has themed styles, the user will probably have to select a palette id
* to extract the raw values from.
*/
get hasThemedStyles(): boolean {
// A theme has themed styles if it has a palette with a mode of "theme"
const themedColors = Object.keys(this.palette).filter((key) => {
return this.palette[key].mode === 'theme';
});
if (themedColors.length > 0) {
return true;
}
return false;
}
get lookup(): LookupTable {
const colorsLookup = getLookupTableForCurrentThemePalettes(this.palette);
const tokensLookup = this.tokensLookupTable;
return { ...colorsLookup, ...tokensLookup };
}
get hasPaletteDefined(): boolean {
if (Object.keys(this.palette).length > 0) {
return true;
}
return false;
}
get hasTokensDefined(): boolean {
if (Object.keys(this.tokens).length > 0) {
return true;
}
return false;
}
get hasStylesDefined(): boolean {
if (Object.keys(this.styles).length > 0) {
return true;
}
return false;
}
get stylesGroupedByBaseComponentType(): { [baseComponent: string]: Style[] } {
const grouped: { [baseComponent: string]: Style[] } = {};
values(this.styles).forEach((style) => {
const baseComponentType = style.baseComponent;
if (!grouped[baseComponentType]) {
grouped[baseComponentType] = [];
}
grouped[baseComponentType].push(style);
});
return grouped;
}
get tokensByType(): Record<string, Token[]> {
const tokens: Record<string, Token[]> = {};
Object.keys(this.tokens).forEach((key) => {
const type = key.split(TOKEN_KEY_SEPARATOR);
if (tokens[type[0]]) {
tokens[type[0]] = [...tokens[type[0]], this.tokens[key]];
} else {
tokens[type[0]] = [this.tokens[key]];
}
});
return tokens;
}
get basePalette() {
return values(this.palette).find((p) => p.mode === 'base');
}
get tokensLookupTable(): LookupTable {
const lookup = getLookupTable(toTokensObject(this.tokens));
return { ...lookup };
}
get reverseTokensLookupTable(): { [id: string]: string } {
const lookup: { [id: string]: string } = {};
Object.keys(this.tokensLookupTable).forEach((key) => {
const value = this.tokensLookupTable[key];
if (hasColorReference(key)) {
// it is a color token
// TODO: do something about that, maybe?
lookup[value] = key;
} else {
lookup[value] = key;
}
});
const colors: any = toColorsObjectFlat(this.palette);
const colorsLookup: { [id: string]: string } = {};
colors.base &&
Object.keys(colors.base)?.forEach((paletteID) => {
const c: any = colors.base[paletteID];
c &&
Object.keys(c)?.forEach((colorID) => {
const rgba = c[colorID].rgba;
colorsLookup[`base.${colorID}`] = rgba;
});
});
colors.theme &&
Object.keys(colors.theme)?.forEach((paletteID) => {
const c: any = colors.theme[paletteID];
c &&
Object.keys(c)?.forEach((colorID) => {
const rgba = c[colorID].rgba;
colorsLookup[`theme.${paletteID}.${colorID}`] = rgba;
});
});
return { ...lookup, ...colorsLookup };
}
static autoGenId = (title: string) => {
if (!title) {
return uuidv4();
}
return title.split(' ').join('-').toLocaleLowerCase();
};
get colors(): Color[] {
let colors: Color[] = [];
Object.keys(this.palette).forEach((paletteId) => {
const paletteColors = this.palette[paletteId].colors;
Object.keys(paletteColors).forEach((id) => {
const color = paletteColors[id];
colors = [...colors, color];
});
});
return colors;
}
get colorsByPalette(): Record<string, Color[]> {
const colors: Record<string, Color[]> = {};
Object.keys(this.palette).forEach((paletteId) => {
colors[paletteId] = [];
const paletteColors = this.palette[paletteId].colors;
Object.keys(paletteColors).forEach((id) => {
const color = paletteColors[id];
colors[paletteId] = [...colors[paletteId], color];
});
});
return colors;
}
get colorsByMode(): Record<string, Color[]> {
const colors: Record<string, Color[]> = {};
Object.keys(this.palette).forEach((paletteId) => {
const palette = this.palette[paletteId];
const mode = palette.mode;
let prefix: string = mode;
if (mode === 'base') {
prefix = `base.${paletteId}`;
} else {
prefix = `theme.${mode}`;
}
colors[prefix] = [];
const paletteColors = palette.colors;
Object.keys(paletteColors).forEach((id) => {
const color = paletteColors[id];
colors[prefix] = [...colors[prefix], color];
});
});
return colors;
}
get modeColorsLength(): number {
let len = 0;
values(this.colorsByMode).forEach((colors) => {
len = len + colors.length;
});
return len;
}
get swatchColors(): ColorDefinition[] {
let colorDefinitions: ColorDefinition[] = [];
Object.keys(this.palette).forEach((paletteId) => {
const paletteColors = this.palette[paletteId].colors;
Object.keys(paletteColors).forEach((id) => {
const color = paletteColors[id];
colorDefinitions = [...colorDefinitions, color.color];
});
});
return colorDefinitions;
}
// cloning the theme means changing the theme id to a brand new id
setNewUniqueId = () => {
this.id = uuidv4();
return this.id;
};
/**
* Clone this theme
* @returns a new theme with a copy of this one, but with a new id
*/
clone = () => {
const newTheme = this;
newTheme.setNewUniqueId();
return newTheme;
};
getThemeObject = (
withColorsFromPaletteId: string
): ApphouseTheme | undefined => {
const currentStyles = this.styles;
const currentTokens = this.tokens;
const currentPalette = this.getPalette('theme', withColorsFromPaletteId);
if (currentPalette) {
const colors = toApphouseColors(currentPalette);
const tokens = toApphouseTokens(currentTokens);
// Lookup should have all the tokens and colors in raw form
const lookupTokens = getLookupTable(tokens);
const lookupColors = getLookupTable(colors, {}, 'theme');
const lookup = { ...lookupTokens, ...lookupColors };
const Apphouse: ApphouseTheme = {
colors,
tokens,
styles: toApphouseStyles({
value: currentStyles || {},
withColorsFromPaletteId: withColorsFromPaletteId,
lookup
})
};
return Apphouse;
}
return undefined;
};
getColorsFromPalettesFromColorId = (
colorId?: string
): { palette: string; rgba: string; color: Color }[] => {
let colors: { palette: string; rgba: string; color: Color }[] = [];
if (colorId) {
Object.keys(this.palette).forEach((paletteId) => {
const paletteColors = this.palette[paletteId].colors;
const c = paletteColors[colorId];
colors = [
...colors,
{
palette: this.palette[paletteId].title,
rgba: c?.rgbString || 'Unknown color',
color: paletteColors[colorId]
}
];
});
}
return colors;
};
setPresentationMode = (value: PresentationModeOption | undefined) => {
this.presentationMode = value;
};
setTitle = (value: string) => {
this.title = value;
};
setId = (value: string) => {
this.id = value;
};
styleStyles = (styles: Record<string, Style>) => {
this.styles = styles;
};
setStyle = (style: Style) => {
this.styles[style.id] = style;
};
addTokenList = (tokens: Token[]) => {
tokens.forEach((token) => {
this.setToken(token);
});
};
addStyles = (styles: StyleType[]) => {
if (Array.isArray(styles)) {
styles.forEach((style) => {
this.styles[style.id] = new Style(style);
});
} else {
// WARNING: Getting here is not expected behavior. We are expecting an array of styles
// but we are getting an object of styles. This can happen if the user is
// importing a theme that has been exported from the apphouse theme editor or
// it does not have the correct format. We attempt to convert the the correct format
// but it might not work.
if (typeof styles === 'object') {
console.log('TODO: convert styles to array');
}
}
};
setToken = (token: Token) => {
this.tokens[getTokenListId(token)] = token;
};
setTokens = (tokens: Record<string, TokenType>) => {
Object.keys(tokens).forEach((key) => {
const token = tokens[key];
this.tokens[getTokenListId(token)] = new Token(token);
});
};
importPalette = (objPalette: PaletteType) => {
const palette = PaletteUtils.objPaletteToPalette(objPalette);
this.palette[palette.id] = palette;
};
importPalettes = (objPalettes: PaletteType[]) => {
objPalettes.forEach((objPalette) => {
this.importPalette(objPalette);
});
};
resetStyles = () => {
this.styles = {};
};
resetTokens = () => {
this.tokens = {};
};
setTokensTokens = (tokens: Record<string, Token>) => {
this.tokens = tokens;
};
addToken = (key: string, value: string, type: TokenTypeOption) => {
const id = `${type}${TOKEN_KEY_SEPARATOR}${key}`.trim();
if (this.tokens[id]) {
// update token instead
this.tokens[id].setKey(key);
this.tokens[id].setValue(value);
this.tokens[id].setType(type);
} else {
const token = new Token({ key, value, type });
this.tokens[id] = token;
}
};
addPalette = (key: string, value: PaletteType) => {
this.palette[key] = new Palette(value);
};
addPalettes = (palettes: { [paletteId: string]: Palette }) => {
this.palette = palettes;
};
setPalette = (palette: Palette) => {
this.palette[palette.id] = palette;
};
addStyle = (
id: string,
value: CssPropertyStyle[],
baseComponent: BaseComponentTypeOption,
variant: string,
state: string,
previewWithTag: TagPreviewOption
) => {
const style = new Style({
id,
value,
baseComponent,
variant,
state,
previewWithTag
});
this.styles[id] = style;
};
appendStyles = (styles: { [selector: string]: Style }) => {
styles &&
Object.keys(styles).forEach((key) => {
this.styles[key] = styles[key];
});
};
removePalette = (key: string) => {
if (this.palette[key]) {
delete this.palette[key];
}
};
removePaletteColor = (paletteId: string, colorId: string): boolean => {
if (this.palette[paletteId]) {
if (this.palette[paletteId].colors[colorId]) {
delete this.palette[paletteId].colors[colorId];
return true;
}
}
return false;
};
removeToken = (key: string) => {
if (this.tokens[key]) {
delete this.tokens[key];
}
};
removeStyle = (key: string): boolean => {
if (this.styles[key]) {
delete this.styles[key];
return true;
}
return false;
};
deleteAllStylesWithKey = (
keyInStyle:
| 'id'
| 'variant'
| 'baseComponent'
| 'value'
| 'state'
| 'previewWithTag',
value: string
) => {
Object.keys(this.styles).forEach((key) => {
const style = this.styles[key];
if (style[keyInStyle] === value) {
delete this.styles[key];
}
});
};
deleteAllTokenTypes = (type: string) => {
Object.keys(this.tokens).forEach((id) => {
const token = this.tokens[id];
if (token.type === type) {
delete this.tokens[id];
}
});
};
getColorKey = (colorDefinition: ColorDefinition): string | undefined => {
let colorKey: string | undefined = undefined;
this.colors.forEach((color) => {
if (
JSON.stringify(color.color.rgb) === JSON.stringify(colorDefinition.rgb)
) {
colorKey = color.id;
}
});
return colorKey;
};
getPalette = (mode: ThemeModeType, id: string): Palette | undefined => {
let wantedPalette: Palette | undefined = undefined;
Object.keys(this.palette).forEach((paletteId) => {
const palette = this.palette[paletteId];
if (palette.mode === mode && paletteId === id) {
wantedPalette = palette;
}
});
return wantedPalette;
};
getColorFromColorId = (colorId: string): Color | undefined => {
const result = this.colors?.find(
(color) => color.id === colorId.toLocaleLowerCase()
);
return result;
};
getFlattenTokensByType = (flattenByType?: string): Record<string, any> => {
const flatten: any = {};
Object.keys(this.palette).forEach((paletteId) => {
flatten[paletteId] = {};
Object.keys(this.tokensByType).forEach((type) => {
if (flattenByType && flattenByType !== type) {
return;
}
flatten[paletteId][type] = {};
const tokens: Token[] = this.tokensByType[type];
tokens.forEach((token) => {
//TODO
let value;
value = getValueFromReferenceString({
referenceString: token.value,
colors: this.palette,
tokens: this.tokens,
theme: paletteId as any,
withRaw: true
});
// let's prefer hex value over rgb
if (typeof value === 'string' && value?.startsWith('rgba')) {
value = rbgaStringToHex(value);
}
flatten[paletteId][type][token.key] = value;
});
});
});
return flatten;
};
static toCamelCase = (str: string) => {
const tocamelcase = (word: string) => {
return `${word.charAt(0).toUpperCase()}${word.slice(1).toLowerCase()}`;
};
const camelcasefy = (dashedProperty: string) => {
const dashedPropertySplit = dashedProperty.split('-');
if (dashedPropertySplit.length <= 1) return dashedProperty;
const camelcase: string[] = [];
dashedPropertySplit.forEach((word, i) => {
if (i === 0) {
camelcase.push(word);
} else {
camelcase.push(tocamelcase(word));
}
});
return camelcase.join('');
};
return camelcasefy(str);
};
reset = () => {
this.palette = {};
this.styles = {};
this.tokens = {};
};
}
export const generateDefaultThemeFromThemeSettings = (
themeSettings: BaseThemeSettings
): Theme => {
const styles = Object.keys(themeSettings.styles).map(
(key) => themeSettings.styles[key]
);
console.log({ styles });
const defaultDarkId = 'dark';
const defaultLightId = 'light';
const dark: PaletteType = {
title: 'Dark',
id: defaultDarkId,
description: 'Dark theme colors',
mode: 'theme',
colors: getDefaultColors(ApphousePaletteModeOptions.dark)
};
const light: PaletteType = {
title: 'Light',
id: defaultLightId,
description: 'Light theme colors',
mode: 'theme',
colors: getDefaultColors(ApphousePaletteModeOptions.light)
};
const base: PaletteType = {
title: 'Base Colors',
id: 'base',
description: 'All colors available for this theme',
mode: 'base',
colors: getDefaultColors(ApphousePaletteModeOptions.base)
};
return new Theme({
title: 'Sample Design System',
id: uuidv4(),
colors: [base, dark, light],
tokens: Object.keys(themeSettings.tokens).map(
(key) => themeSettings.tokens[key]
),
styles
});
};
export const getLookupTableForThisAppColors = () => {
const palettes: Record<string, Palette> = {};
palettes['dark'] = new Palette({
title: 'Dark',
id: 'dark',
description: 'Dark theme colors',
mode: 'theme',
colors: getDefaultColors(ApphousePaletteModeOptions.dark)
});
palettes['light'] = new Palette({
title: 'Light',
id: 'light',
description: 'Light theme colors',
mode: 'theme',
colors: getDefaultColors(ApphousePaletteModeOptions.light)
});
palettes['base'] = new Palette({
title: 'Base Colors',
id: 'base',
description: 'All colors available for this theme',
mode: 'base',
colors: getDefaultColors(ApphousePaletteModeOptions.base)
});
return {
...getColorTokensLookupReferenceFromPalettes(palettes, 'base'),
...getColorTokensLookupReferenceFromPalettes(palettes, 'base', 'dark'),
...getColorTokensLookupReferenceFromPalettes(palettes, 'base', 'light')
};
};
export const getLookupTableForThisApp = () => {
const colorsLookup = getLookupTableForThisAppColors();
const tokensLookup = getLookupTable(ApphouseThemeTokens, {});
return { ...colorsLookup, ...tokensLookup };
};
export const getLookupTableForCurrentThemePalettes = (
palettes: Record<string, Palette>
) => {
let lookup = {};
let basePaletteId = '';
Object.keys(palettes).forEach((paletteId) => {
const paletteMode = palettes[paletteId].mode;
if (paletteMode === 'base') {
basePaletteId = palettes[paletteId].id;
lookup = {
...lookup,
...getColorTokensLookupReferenceFromPalettes(palettes, 'base')
};
}
});
Object.keys(palettes).forEach((paletteId) => {
const paletteMode = palettes[paletteId].mode;
if (paletteMode === 'theme') {
lookup = {
...lookup,
...getColorTokensLookupReferenceFromPalettes(
palettes,
basePaletteId,
paletteId
)
};
}
});
return lookup;
};