lupine.web
Version:
lupine.web is a extremely fast, small size and lightweight frontend framework, using React TSX syntax.
240 lines (221 loc) • 8.7 kB
text/typescript
import { uniqueIdGenerator } from 'lupine.components';
import { CssProps } from '../jsx';
import { getCurrentTheme, themeCookieName } from './bind-theme';
import { camelToHyphens } from './camel-to-hyphens';
// import { bindPageResetEvent } from './page-reset-events';
import { bindPageLoadedEvent } from './page-loaded-events';
const wrapCss = (className: string, cssText: string, mediaQuery?: string) => {
// if (!className) {
// console.warn(`No class name is provided for ${cssText}`);
// }
let cssTextWrap = className ? `${className}{${cssText}}` : cssText;
if (mediaQuery) {
cssTextWrap = `${mediaQuery}{${cssTextWrap}}`;
}
return cssTextWrap;
};
const processStyleValue = (style: CssProps) => {
return Object.keys(style)
.map((key) => key.trim())
.map((key) => {
const noOutput =
(style[key] != null && typeof style[key] === 'object') ||
typeof style[key] === 'undefined' ||
style[key] === '';
return noOutput ? '' : `${camelToHyphens(key)}:${style[key]};`;
})
.join('');
};
const updateOneBlock = (css: string[], cssTemp: string[], className: string, mediaQuery?: string) => {
if (cssTemp.length > 0) {
const cssText = wrapCss(className, cssTemp.join(''), mediaQuery);
css.push(cssText);
cssTemp.length = 0;
}
};
const processStyleSub = (
topUniqueClassName: string,
classSelector: string,
style: CssProps,
mediaQuery?: string
): string[] => {
const outClassName = classSelector
.split(',')
.map((key0) => key0.trim())
.map((key0) => {
return (key0.startsWith('&') ? `${classSelector}${key0.substring(1)}` : key0).replace(/&/g, topUniqueClassName);
})
.join(',');
const css: string[] = [];
const cssTemp: string[] = [];
for (let i in style) {
const value = style[i];
if (value === null || typeof value !== 'object') {
if (value !== '' && typeof value !== 'undefined') {
if (!classSelector) {
console.warn(`No className is defined for: ${camelToHyphens(i)}:${value};`);
}
cssTemp.push(`${camelToHyphens(i)}:${value};`);
}
} else {
updateOneBlock(css, cssTemp, outClassName, mediaQuery);
if (i.startsWith('@keyframes')) {
const cssText = Object.keys(value)
.map((stageKey) => stageKey + '{' + processStyleValue(value[stageKey] as CssProps) + '}')
.join('');
css.push(`${i}{${cssText}}`);
} else if (i.startsWith('@media')) {
const ret = processStyleSub(topUniqueClassName, classSelector, value, i);
css.push(...ret);
} else {
// '&:hover, &.open': {
// '>.d1, .d2': {...},
// }, ==>
// &:hover >.d1, &:hover >.d2, &.open >.d1, &.open .d2
// '.aa': {
// '&:hover': {...},
// '.bb': {...},
// }, ==>
// .aa:hover, .aa .bb
const newClassSelector = !classSelector
? i
: classSelector
.split(',')
.map((key0) => key0.trim())
.map((key0) => {
return i
.split(',')
.map((key) => key.trim())
.map((key) => {
// not needed to "+" as them share same parents?
// return key.split('+').map(key2 => key2.startsWith('&') ? key0 + key2.substring(1) : key0 + ' ' + key2).join('+');
// return key.startsWith('&') ? key0 + key.substring(1) : key0 + ' ' + key;
const newKey = key.startsWith('&') ? key0 + key.substring(1) : key0 + ' ' + key;
return newKey.replace(/&/g, topUniqueClassName);
})
.join(',');
})
.join(',');
const ret = processStyleSub(topUniqueClassName, newClassSelector, value, mediaQuery);
css.push(...ret);
}
}
}
updateOneBlock(css, cssTemp, outClassName, mediaQuery);
return css;
};
// topUniqueClassName is used to replace '&' in className, and '.' + topUniqueClassName is used as selector in styles ".xxx {}"
export const processStyle = (topUniqueClassName: string, style: CssProps): string[] => {
return processStyleSub(topUniqueClassName, topUniqueClassName ? `.${topUniqueClassName}` : '', style);
};
// mount-components has the same name `sty-`
export const updateStyles = (topUniqueClassName: string, style: CssProps) => {
const el = topUniqueClassName && document.querySelector(`.${topUniqueClassName}`);
if (el) {
const cssText = processStyle(topUniqueClassName, style).join('');
// if the first child is style, then update it
if (el.firstChild && el.firstChild.nodeName === 'STYLE') {
(el.firstChild as any).innerHTML = cssText;
} else {
const style = document.createElement('style');
style.innerHTML = cssText;
// style.id = `sty-${selector}`; // sty means style, this is different from mount-components.renderComponent
el.prepend(style);
}
} else {
console.warn(`Can't find "${topUniqueClassName}" to update styles.`);
}
};
const updateCssDom = (uniqueStyleId: string, cssText: string, cssDom: HTMLElement | null) => {
if (!cssDom) {
cssDom = document.createElement('style');
cssDom.id = `sty-${uniqueStyleId}`;
document.head.appendChild(cssDom);
}
cssDom.innerText = cssText;
};
/*
If selectors in GlobalStyles have '&' at top level, then classSelector is needed, otherwise classSelector can be ''
This is ok:
{
'.aa':{
'&:hover': {...}
}
}
This needs classSelector:
{
'color': 'red', // will need and be put under .topUniqueClassName
'&:hover': {...} // & will be replaced by .topUniqueClassName
}
Global styles including theme will not be updated once it's created.
topUniqueClassName is a className or a tag name.
classSelector is a selector used in styles ".xxx {}"
For example, it can be like this for all elements:
html { ... } or :root { ... }
For themes like [data-theme="dark" i], the topUniqueClassName should be empty
*/
export const globalStyleUniqueId = uniqueIdGenerator('g'); // g means global style
const _globalStyleIds = new Map<CssProps, string>();
export const getGlobalStylesId = (style: CssProps): string => {
if (!_globalStyleIds.has(style)) {
const id = globalStyleUniqueId();
_globalStyleIds.set(style, id);
}
return _globalStyleIds.get(style)!;
};
const _globalStyle = new Map();
export const bindGlobalStyle = (
topUniqueClassName: string,
style: CssProps,
forceUpdate = false,
noTopClassName = false
) => {
if (typeof document !== 'undefined') {
let cssDom = document.getElementById(`sty-${topUniqueClassName}`);
if (forceUpdate || !cssDom) {
updateCssDom(topUniqueClassName, processStyle(noTopClassName ? '' : topUniqueClassName, style).join(''), cssDom);
}
} else if (!_globalStyle.has(topUniqueClassName) || forceUpdate) {
// don't overwrite it to have the same behavior as in the Browser
_globalStyle.set(topUniqueClassName, { topUniqueClassName, noTopClassName, style });
}
};
const generateThemeStyles = () => {
const currentTheme = getCurrentTheme();
const themeCss = [];
for (let themeName in currentTheme.themes) {
// i is for case-insensitive
themeCss.push(
...processStyle('', {
[`[data-theme="${themeName}" i]`]: currentTheme.themes[themeName],
})
);
}
return themeCss.join('\n');
};
if (typeof document !== 'undefined') {
// Update theme in Browser when no SSR
bindPageLoadedEvent(() => {
const uniqueStyleId = themeCookieName;
let cssDom = document.getElementById(`sty-${uniqueStyleId}`);
if (!cssDom) {
updateCssDom(uniqueStyleId, generateThemeStyles(), cssDom);
}
});
}
// can't clear global styles,because in index.tsx it is only loaded once, clear it it will be gone
// const clearGlobalStyles = () => {
// // reset unique id
// _globalStyle.clear();
// };
// bindPageResetEvent(clearGlobalStyles);
export const generateAllGlobalStyles = () => {
const result = [];
result.push(`<style id="sty-${themeCookieName}">${generateThemeStyles()}</style>`);
for (let [uniqueStyleId, { topUniqueClassName, noTopClassName, style }] of _globalStyle) {
const cssText = processStyle(noTopClassName ? '' : topUniqueClassName, style).join('');
result.push(`<style id="sty-${uniqueStyleId}">${cssText}</style>`);
}
// clearGlobalStyles();
return result.join('');
};