UNPKG

apphouse

Version:

Component library for React that uses observable state management and theme-able components.

739 lines (653 loc) 20.2 kB
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; };