stoop 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/create-theme.js +43 -0
- package/dist/api/css.js +20 -0
- package/dist/api/global-css.d.ts +0 -11
- package/dist/api/global-css.js +89 -0
- package/dist/api/keyframes.js +95 -0
- package/dist/api/provider.d.ts +3 -3
- package/dist/api/provider.js +109 -0
- package/dist/api/styled.js +170 -0
- package/dist/api/use-theme.js +21 -0
- package/dist/constants.d.ts +2 -3
- package/dist/constants.js +144 -0
- package/dist/core/cache.d.ts +5 -9
- package/dist/core/cache.js +68 -0
- package/dist/core/compiler.js +198 -0
- package/dist/core/theme-manager.d.ts +0 -8
- package/dist/core/theme-manager.js +97 -0
- package/dist/core/variants.js +32 -0
- package/dist/create-stoop-server.d.ts +33 -0
- package/dist/create-stoop-server.js +130 -0
- package/dist/create-stoop.d.ts +2 -5
- package/dist/create-stoop.js +147 -0
- package/dist/index.js +5 -13
- package/dist/inject/browser.d.ts +2 -3
- package/dist/inject/browser.js +149 -0
- package/dist/inject/dedup.js +38 -0
- package/dist/inject/index.d.ts +0 -1
- package/dist/inject/index.js +75 -0
- package/dist/inject/ssr.d.ts +0 -1
- package/dist/inject/ssr.js +46 -0
- package/dist/ssr.d.ts +21 -0
- package/dist/ssr.js +19 -0
- package/dist/types/index.d.ts +10 -5
- package/dist/types/index.js +5 -0
- package/dist/types/react-polymorphic-types.d.ts +4 -8
- package/dist/utils/environment.d.ts +6 -0
- package/dist/utils/environment.js +12 -0
- package/dist/utils/string.d.ts +16 -9
- package/dist/utils/string.js +253 -0
- package/dist/utils/theme-map.d.ts +0 -3
- package/dist/utils/theme-map.js +97 -0
- package/dist/utils/theme-validation.js +36 -0
- package/dist/utils/theme.d.ts +3 -3
- package/dist/utils/theme.js +279 -0
- package/dist/utils/type-guards.js +38 -0
- package/dist/utils/utilities.js +43 -0
- package/package.json +24 -25
- package/LICENSE.md +0 -21
- package/README.md +0 -180
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme extension API.
|
|
3
|
+
* Creates a function that deep merges theme overrides with a base theme.
|
|
4
|
+
*/
|
|
5
|
+
import { validateTheme } from "../utils/theme-validation";
|
|
6
|
+
import { isThemeObject } from "../utils/type-guards";
|
|
7
|
+
/**
|
|
8
|
+
* Creates a function that extends a base theme with overrides.
|
|
9
|
+
* The returned function deep merges theme overrides with the base theme.
|
|
10
|
+
*
|
|
11
|
+
* @param baseTheme - Base theme to extend
|
|
12
|
+
* @returns Function that accepts theme overrides and returns a merged theme
|
|
13
|
+
*/
|
|
14
|
+
export function createTheme(baseTheme) {
|
|
15
|
+
return function createTheme(themeOverrides) {
|
|
16
|
+
const validatedOverrides = validateTheme(themeOverrides);
|
|
17
|
+
function deepMerge(target, source) {
|
|
18
|
+
const result = { ...target };
|
|
19
|
+
const sourceKeys = Object.keys(source);
|
|
20
|
+
for (const key of sourceKeys) {
|
|
21
|
+
const sourceValue = source[key];
|
|
22
|
+
const targetValue = target[key];
|
|
23
|
+
if (isThemeObject(sourceValue) && isThemeObject(targetValue)) {
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
25
|
+
result[key] = { ...targetValue, ...sourceValue };
|
|
26
|
+
}
|
|
27
|
+
else if (sourceValue !== undefined) {
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
|
+
result[key] = sourceValue;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const targetKeys = Object.keys(target);
|
|
33
|
+
for (const key of targetKeys) {
|
|
34
|
+
if (!(key in result)) {
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
36
|
+
result[key] = target[key];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
return deepMerge(baseTheme, validatedOverrides);
|
|
42
|
+
};
|
|
43
|
+
}
|
package/dist/api/css.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS class generation API.
|
|
3
|
+
* Creates a function that compiles CSS objects into class names.
|
|
4
|
+
*/
|
|
5
|
+
import { compileCSS } from "../core/compiler";
|
|
6
|
+
/**
|
|
7
|
+
* Creates a CSS function that compiles CSS objects into class names.
|
|
8
|
+
*
|
|
9
|
+
* @param defaultTheme - Default theme for token resolution
|
|
10
|
+
* @param prefix - Optional prefix for generated class names
|
|
11
|
+
* @param media - Optional media query breakpoints
|
|
12
|
+
* @param utils - Optional utility functions
|
|
13
|
+
* @param themeMap - Optional theme scale mappings
|
|
14
|
+
* @returns Function that accepts CSS objects and returns class names
|
|
15
|
+
*/
|
|
16
|
+
export function createCSSFunction(defaultTheme, prefix = "stoop", media, utils, themeMap) {
|
|
17
|
+
return function css(styles) {
|
|
18
|
+
return compileCSS(styles, defaultTheme, prefix, media, utils, themeMap);
|
|
19
|
+
};
|
|
20
|
+
}
|
package/dist/api/global-css.d.ts
CHANGED
|
@@ -4,15 +4,4 @@
|
|
|
4
4
|
* Supports media queries, nested selectors, and theme tokens.
|
|
5
5
|
*/
|
|
6
6
|
import type { CSS, Theme, ThemeScale, UtilityFunction } from "../types";
|
|
7
|
-
/**
|
|
8
|
-
* Creates a global CSS injection function.
|
|
9
|
-
* Injects styles directly into the document with deduplication support.
|
|
10
|
-
*
|
|
11
|
-
* @param defaultTheme - Default theme for token resolution
|
|
12
|
-
* @param prefix - Optional prefix for CSS rules
|
|
13
|
-
* @param media - Optional media query breakpoints
|
|
14
|
-
* @param utils - Optional utility functions
|
|
15
|
-
* @param themeMap - Optional theme scale mappings
|
|
16
|
-
* @returns Function that accepts CSS objects and returns a cleanup function
|
|
17
|
-
*/
|
|
18
7
|
export declare function createGlobalCSSFunction(defaultTheme: Theme, prefix?: string, media?: Record<string, string>, utils?: Record<string, UtilityFunction>, themeMap?: Record<string, ThemeScale>): (styles: CSS) => () => void;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global CSS injection API.
|
|
3
|
+
* Creates a function that injects global styles into the document.
|
|
4
|
+
* Supports media queries, nested selectors, and theme tokens.
|
|
5
|
+
*/
|
|
6
|
+
import { MAX_CSS_NESTING_DEPTH } from "../constants";
|
|
7
|
+
import { injectCSS } from "../inject";
|
|
8
|
+
import { escapeCSSValue, hashObject, sanitizeCSSPropertyName, sanitizeCSSSelector, sanitizeMediaQuery, sanitizePrefix, } from "../utils/string";
|
|
9
|
+
import { replaceThemeTokensWithVars } from "../utils/theme";
|
|
10
|
+
import { isCSSObject } from "../utils/type-guards";
|
|
11
|
+
import { applyUtilities } from "../utils/utilities";
|
|
12
|
+
/**
|
|
13
|
+
* Creates a global CSS injection function.
|
|
14
|
+
* Injects styles directly into the document with deduplication support.
|
|
15
|
+
*
|
|
16
|
+
* @param defaultTheme - Default theme for token resolution
|
|
17
|
+
* @param prefix - Optional prefix for CSS rules
|
|
18
|
+
* @param media - Optional media query breakpoints
|
|
19
|
+
* @param utils - Optional utility functions
|
|
20
|
+
* @param themeMap - Optional theme scale mappings
|
|
21
|
+
* @returns Function that accepts CSS objects and returns a cleanup function
|
|
22
|
+
*/
|
|
23
|
+
const globalInjectedStyles = new WeakMap();
|
|
24
|
+
function getInjectedSet(theme) {
|
|
25
|
+
let set = globalInjectedStyles.get(theme);
|
|
26
|
+
if (!set) {
|
|
27
|
+
set = new Set();
|
|
28
|
+
globalInjectedStyles.set(theme, set);
|
|
29
|
+
}
|
|
30
|
+
return set;
|
|
31
|
+
}
|
|
32
|
+
export function createGlobalCSSFunction(defaultTheme, prefix = "stoop", media, utils, themeMap) {
|
|
33
|
+
return function globalCss(styles) {
|
|
34
|
+
const injected = getInjectedSet(defaultTheme);
|
|
35
|
+
const cssKey = hashObject(styles);
|
|
36
|
+
if (injected.has(cssKey)) {
|
|
37
|
+
return () => { };
|
|
38
|
+
}
|
|
39
|
+
injected.add(cssKey);
|
|
40
|
+
function generateGlobalCSS(obj, depth = 0) {
|
|
41
|
+
if (depth > MAX_CSS_NESTING_DEPTH) {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
let result = "";
|
|
45
|
+
const sortedEntries = Object.entries(obj).sort(([a], [b]) => a.localeCompare(b));
|
|
46
|
+
sortedEntries.forEach(([key, value]) => {
|
|
47
|
+
if (isCSSObject(value)) {
|
|
48
|
+
if (media && key in media) {
|
|
49
|
+
const mediaQuery = sanitizeMediaQuery(media[key]);
|
|
50
|
+
if (mediaQuery) {
|
|
51
|
+
const nestedCss = generateGlobalCSS(value, depth + 1);
|
|
52
|
+
result += `${mediaQuery} { ${nestedCss} }`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else if (key.startsWith("@")) {
|
|
56
|
+
const sanitizedKey = sanitizeCSSSelector(key);
|
|
57
|
+
if (sanitizedKey) {
|
|
58
|
+
const nestedCss = generateGlobalCSS(value, depth + 1);
|
|
59
|
+
result += `${sanitizedKey} { ${nestedCss} }`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const sanitizedKey = sanitizeCSSSelector(key);
|
|
64
|
+
if (sanitizedKey) {
|
|
65
|
+
const nestedCss = generateGlobalCSS(value, depth + 1);
|
|
66
|
+
result += `${sanitizedKey} { ${nestedCss} }`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else if (value !== undefined) {
|
|
71
|
+
const property = sanitizeCSSPropertyName(key);
|
|
72
|
+
if (property && (typeof value === "string" || typeof value === "number")) {
|
|
73
|
+
const escapedValue = escapeCSSValue(value);
|
|
74
|
+
result += `${property}: ${escapedValue}; `;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
const sanitizedPrefix = sanitizePrefix(prefix);
|
|
81
|
+
const stylesWithUtils = applyUtilities(styles, utils);
|
|
82
|
+
const themedStyles = replaceThemeTokensWithVars(stylesWithUtils, defaultTheme, themeMap);
|
|
83
|
+
const cssText = generateGlobalCSS(themedStyles);
|
|
84
|
+
injectCSS(cssText, sanitizedPrefix, `__global_${cssKey}`);
|
|
85
|
+
return () => {
|
|
86
|
+
injected.delete(cssKey);
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS keyframes animation API.
|
|
3
|
+
* Creates a function that generates and injects @keyframes rules.
|
|
4
|
+
* Caches animations by content hash to prevent duplicates.
|
|
5
|
+
*/
|
|
6
|
+
import { LRUCache } from "../core/cache";
|
|
7
|
+
import { injectCSS } from "../inject";
|
|
8
|
+
import { hashObject, sanitizeCSSPropertyName, sanitizePrefix, validateKeyframeKey, } from "../utils/string";
|
|
9
|
+
import { replaceThemeTokensWithVars } from "../utils/theme";
|
|
10
|
+
/**
|
|
11
|
+
* Converts a keyframes object to a CSS @keyframes rule string.
|
|
12
|
+
*
|
|
13
|
+
* @param keyframesObj - Keyframes object with percentage/from/to keys
|
|
14
|
+
* @param animationName - Name for the animation
|
|
15
|
+
* @param theme - Optional theme for token resolution
|
|
16
|
+
* @param themeMap - Optional theme scale mappings
|
|
17
|
+
* @returns CSS @keyframes rule string
|
|
18
|
+
*/
|
|
19
|
+
function keyframesToCSS(keyframesObj, animationName, theme, themeMap) {
|
|
20
|
+
let css = `@keyframes ${animationName} {`;
|
|
21
|
+
const sortedKeys = Object.keys(keyframesObj).sort((a, b) => {
|
|
22
|
+
const aNum = parseFloat(a.replace("%", ""));
|
|
23
|
+
const bNum = parseFloat(b.replace("%", ""));
|
|
24
|
+
if (a === "from") {
|
|
25
|
+
return -1;
|
|
26
|
+
}
|
|
27
|
+
if (b === "from") {
|
|
28
|
+
return 1;
|
|
29
|
+
}
|
|
30
|
+
if (a === "to") {
|
|
31
|
+
return 1;
|
|
32
|
+
}
|
|
33
|
+
if (b === "to") {
|
|
34
|
+
return -1;
|
|
35
|
+
}
|
|
36
|
+
return aNum - bNum;
|
|
37
|
+
});
|
|
38
|
+
for (const key of sortedKeys) {
|
|
39
|
+
if (!validateKeyframeKey(key)) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const styles = keyframesObj[key];
|
|
43
|
+
if (!styles || typeof styles !== "object") {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
css += ` ${key} {`;
|
|
47
|
+
const themedStyles = replaceThemeTokensWithVars(styles, theme, themeMap);
|
|
48
|
+
// Sort properties for deterministic CSS generation
|
|
49
|
+
const sortedProps = Object.keys(themedStyles).sort();
|
|
50
|
+
for (const prop of sortedProps) {
|
|
51
|
+
const value = themedStyles[prop];
|
|
52
|
+
if (value !== undefined && (typeof value === "string" || typeof value === "number")) {
|
|
53
|
+
const sanitizedProp = sanitizeCSSPropertyName(prop);
|
|
54
|
+
if (sanitizedProp) {
|
|
55
|
+
// Don't escape keyframe values - escaping breaks complex CSS functions
|
|
56
|
+
const cssValue = String(value);
|
|
57
|
+
css += ` ${sanitizedProp}: ${cssValue};`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
css += " }";
|
|
62
|
+
}
|
|
63
|
+
css += " }";
|
|
64
|
+
return css;
|
|
65
|
+
}
|
|
66
|
+
const KEYFRAME_CACHE_LIMIT = 500;
|
|
67
|
+
/**
|
|
68
|
+
* Creates a keyframes animation function.
|
|
69
|
+
* Generates and injects @keyframes rules with caching to prevent duplicates.
|
|
70
|
+
*
|
|
71
|
+
* @param prefix - Optional prefix for animation names
|
|
72
|
+
* @param theme - Optional theme for token resolution
|
|
73
|
+
* @param themeMap - Optional theme scale mappings
|
|
74
|
+
* @returns Function that accepts keyframes objects and returns animation names
|
|
75
|
+
*/
|
|
76
|
+
export function createKeyframesFunction(prefix = "stoop", theme, themeMap) {
|
|
77
|
+
const sanitizedPrefix = sanitizePrefix(prefix);
|
|
78
|
+
const animationCache = new LRUCache(KEYFRAME_CACHE_LIMIT);
|
|
79
|
+
return function keyframes(keyframesObj) {
|
|
80
|
+
const keyframesKey = hashObject(keyframesObj);
|
|
81
|
+
const cachedName = animationCache.get(keyframesKey);
|
|
82
|
+
if (cachedName) {
|
|
83
|
+
return cachedName;
|
|
84
|
+
}
|
|
85
|
+
const hashValue = keyframesKey.slice(0, 8);
|
|
86
|
+
const animationName = sanitizedPrefix
|
|
87
|
+
? `${sanitizedPrefix}-${hashValue}`
|
|
88
|
+
: `stoop-${hashValue}`;
|
|
89
|
+
const css = keyframesToCSS(keyframesObj, animationName, theme, themeMap);
|
|
90
|
+
const ruleKey = `__keyframes_${animationName}`;
|
|
91
|
+
injectCSS(css, sanitizedPrefix, ruleKey);
|
|
92
|
+
animationCache.set(keyframesKey, animationName);
|
|
93
|
+
return animationName;
|
|
94
|
+
};
|
|
95
|
+
}
|
package/dist/api/provider.d.ts
CHANGED
|
@@ -7,13 +7,13 @@ import type { ProviderProps, Theme, ThemeContextValue, ThemeManagementContextVal
|
|
|
7
7
|
/**
|
|
8
8
|
* Creates a Provider component for theme management.
|
|
9
9
|
*
|
|
10
|
-
* @param ThemeContext - Stoop's theme context for styled components
|
|
11
10
|
* @param themes - Map of theme names to theme objects
|
|
12
11
|
* @param defaultTheme - Default theme object
|
|
13
12
|
* @param prefix - Optional prefix for CSS variable scoping
|
|
14
|
-
* @returns Provider component and theme management context
|
|
13
|
+
* @returns Provider component, theme context, and theme management context
|
|
15
14
|
*/
|
|
16
|
-
export declare function createProvider(
|
|
15
|
+
export declare function createProvider(themes: Record<string, Theme>, defaultTheme: Theme, prefix?: string): {
|
|
17
16
|
Provider: ComponentType<ProviderProps>;
|
|
17
|
+
ThemeContext: Context<ThemeContextValue | null>;
|
|
18
18
|
ThemeManagementContext: Context<ThemeManagementContextValue | null>;
|
|
19
19
|
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* Theme Provider component.
|
|
5
|
+
* Manages theme state, localStorage persistence, and centralized theme variable updates.
|
|
6
|
+
*/
|
|
7
|
+
import { createContext, useCallback, useLayoutEffect, useMemo, useState, } from "react";
|
|
8
|
+
import { updateThemeVariables } from "../core/theme-manager";
|
|
9
|
+
import { isBrowser } from "../utils/environment";
|
|
10
|
+
/**
|
|
11
|
+
* Creates a Provider component for theme management.
|
|
12
|
+
*
|
|
13
|
+
* @param themes - Map of theme names to theme objects
|
|
14
|
+
* @param defaultTheme - Default theme object
|
|
15
|
+
* @param prefix - Optional prefix for CSS variable scoping
|
|
16
|
+
* @returns Provider component, theme context, and theme management context
|
|
17
|
+
*/
|
|
18
|
+
export function createProvider(themes, defaultTheme, prefix = "stoop") {
|
|
19
|
+
const ThemeContext = createContext(null);
|
|
20
|
+
const ThemeManagementContext = createContext(null);
|
|
21
|
+
const availableThemeNames = Object.keys(themes);
|
|
22
|
+
const firstThemeName = availableThemeNames[0] || "default";
|
|
23
|
+
function Provider({ attribute = "data-theme", children, defaultTheme: defaultThemeProp, storageKey = "stoop-theme", }) {
|
|
24
|
+
// SSR-safe initialization: always start with default, then hydrate from localStorage
|
|
25
|
+
const [themeName, setThemeNameState] = useState(() => {
|
|
26
|
+
// During SSR, always return the default theme
|
|
27
|
+
if (!isBrowser()) {
|
|
28
|
+
return defaultThemeProp || firstThemeName;
|
|
29
|
+
}
|
|
30
|
+
// On client, try to read from localStorage
|
|
31
|
+
try {
|
|
32
|
+
const stored = localStorage.getItem(storageKey);
|
|
33
|
+
if (stored && themes[stored]) {
|
|
34
|
+
return stored;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// localStorage access failed (e.g., in private browsing mode)
|
|
39
|
+
}
|
|
40
|
+
return defaultThemeProp || firstThemeName;
|
|
41
|
+
});
|
|
42
|
+
// Hydrate from localStorage after mount to prevent hydration mismatch
|
|
43
|
+
useLayoutEffect(() => {
|
|
44
|
+
if (!isBrowser()) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const stored = localStorage.getItem(storageKey);
|
|
49
|
+
if (stored && themes[stored] && stored !== themeName) {
|
|
50
|
+
setThemeNameState(stored);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// localStorage access failed
|
|
55
|
+
}
|
|
56
|
+
}, [storageKey, themeName]);
|
|
57
|
+
const currentTheme = useMemo(() => {
|
|
58
|
+
return themes[themeName] || themes[defaultThemeProp || firstThemeName] || defaultTheme;
|
|
59
|
+
}, [themeName, defaultThemeProp, firstThemeName, themes]);
|
|
60
|
+
useLayoutEffect(() => {
|
|
61
|
+
if (currentTheme) {
|
|
62
|
+
updateThemeVariables(currentTheme, prefix);
|
|
63
|
+
}
|
|
64
|
+
}, [currentTheme, prefix]);
|
|
65
|
+
useLayoutEffect(() => {
|
|
66
|
+
if (isBrowser() && attribute) {
|
|
67
|
+
document.documentElement.setAttribute(attribute, themeName);
|
|
68
|
+
}
|
|
69
|
+
}, [themeName, attribute]);
|
|
70
|
+
const setTheme = useCallback((newThemeName) => {
|
|
71
|
+
if (themes[newThemeName]) {
|
|
72
|
+
setThemeNameState(newThemeName);
|
|
73
|
+
try {
|
|
74
|
+
localStorage.setItem(storageKey, newThemeName);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// localStorage access failed (e.g., in private browsing mode)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else if (typeof process !== "undefined" && process.env?.NODE_ENV !== "production") {
|
|
81
|
+
// eslint-disable-next-line no-console
|
|
82
|
+
console.warn(`[Stoop] Theme "${newThemeName}" not found. Available themes: ${availableThemeNames.join(", ")}`);
|
|
83
|
+
}
|
|
84
|
+
}, [storageKey, themes, availableThemeNames]);
|
|
85
|
+
const themeContextValue = useMemo(() => ({
|
|
86
|
+
theme: currentTheme,
|
|
87
|
+
themeName,
|
|
88
|
+
}), [currentTheme, themeName]);
|
|
89
|
+
const toggleTheme = useCallback(() => {
|
|
90
|
+
const currentIndex = availableThemeNames.indexOf(themeName);
|
|
91
|
+
const nextIndex = (currentIndex + 1) % availableThemeNames.length;
|
|
92
|
+
const newTheme = availableThemeNames[nextIndex];
|
|
93
|
+
setTheme(newTheme);
|
|
94
|
+
}, [themeName, setTheme]);
|
|
95
|
+
const managementContextValue = useMemo(() => ({
|
|
96
|
+
availableThemes: availableThemeNames,
|
|
97
|
+
setTheme,
|
|
98
|
+
theme: currentTheme,
|
|
99
|
+
themeName,
|
|
100
|
+
toggleTheme,
|
|
101
|
+
}), [currentTheme, themeName, setTheme, toggleTheme]);
|
|
102
|
+
return (_jsx(ThemeContext.Provider, { value: themeContextValue, children: _jsx(ThemeManagementContext.Provider, { value: managementContextValue, children: children }) }));
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
Provider,
|
|
106
|
+
ThemeContext,
|
|
107
|
+
ThemeManagementContext,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
/**
|
|
3
|
+
* Styled component API.
|
|
4
|
+
* Creates polymorphic styled components with variant support, theme awareness,
|
|
5
|
+
* and CSS prop merging. Supports component targeting via selector references.
|
|
6
|
+
*/
|
|
7
|
+
import { useMemo, forwardRef, createElement, useContext, createContext, } from "react";
|
|
8
|
+
import { EMPTY_CSS, STOOP_COMPONENT_SYMBOL } from "../constants";
|
|
9
|
+
import { compileCSS } from "../core/compiler";
|
|
10
|
+
import { applyVariants } from "../core/variants";
|
|
11
|
+
import { hash, sanitizeClassName } from "../utils/string";
|
|
12
|
+
let defaultThemeContext = null;
|
|
13
|
+
function getDefaultThemeContext() {
|
|
14
|
+
if (!defaultThemeContext) {
|
|
15
|
+
defaultThemeContext = createContext(null);
|
|
16
|
+
}
|
|
17
|
+
return defaultThemeContext;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Creates a styled component reference for selector targeting.
|
|
21
|
+
*
|
|
22
|
+
* @param className - Class name to reference
|
|
23
|
+
* @returns StyledComponentRef for use in CSS selectors
|
|
24
|
+
*/
|
|
25
|
+
export function createStyledComponentRef(className) {
|
|
26
|
+
const ref = {
|
|
27
|
+
__isStoopStyled: true,
|
|
28
|
+
__stoopClassName: className,
|
|
29
|
+
[STOOP_COMPONENT_SYMBOL]: className,
|
|
30
|
+
toString: () => `__STOOP_COMPONENT_${className}`,
|
|
31
|
+
};
|
|
32
|
+
return ref;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Type guard for styled component references.
|
|
36
|
+
*
|
|
37
|
+
* @param value - Value to check
|
|
38
|
+
* @returns True if value is a styled component reference
|
|
39
|
+
*/
|
|
40
|
+
function isStyledComponent(value) {
|
|
41
|
+
return (typeof value === "object" &&
|
|
42
|
+
value !== null &&
|
|
43
|
+
"__isStoopStyled" in value &&
|
|
44
|
+
value.__isStoopStyled === true);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Separates component props into variant props and element props.
|
|
48
|
+
* Variant props are used for style variants, element props are passed to the DOM element.
|
|
49
|
+
*
|
|
50
|
+
* @param props - All component props
|
|
51
|
+
* @param variants - Variant configuration
|
|
52
|
+
* @returns Object with separated elementProps and variantProps
|
|
53
|
+
*/
|
|
54
|
+
function extractVariantProps(props, variants) {
|
|
55
|
+
if (!variants) {
|
|
56
|
+
return { elementProps: props, variantProps: {} };
|
|
57
|
+
}
|
|
58
|
+
const variantKeys = new Set(Object.keys(variants));
|
|
59
|
+
const variantProps = {};
|
|
60
|
+
const elementProps = {};
|
|
61
|
+
for (const key in props) {
|
|
62
|
+
if (variantKeys.has(key)) {
|
|
63
|
+
variantProps[key] = props[key];
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
elementProps[key] = props[key];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { elementProps, variantProps };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Creates a styled component factory function.
|
|
73
|
+
* Supports polymorphic components, variants, theme awareness, and CSS prop merging.
|
|
74
|
+
*
|
|
75
|
+
* @param defaultTheme - Default theme for token resolution
|
|
76
|
+
* @param prefix - Optional prefix for generated class names
|
|
77
|
+
* @param media - Optional media query breakpoints
|
|
78
|
+
* @param utils - Optional utility functions
|
|
79
|
+
* @param themeMap - Optional theme scale mappings
|
|
80
|
+
* @param themeContext - React context for theme values (instance-specific)
|
|
81
|
+
* @returns Styled component factory function
|
|
82
|
+
*/
|
|
83
|
+
export function createStyledFunction(defaultTheme, prefix = "stoop", media, utils, themeMap, themeContext) {
|
|
84
|
+
return function styled(defaultElement, baseStylesOrVariants, variantsParam) {
|
|
85
|
+
let actualBaseStyles = (baseStylesOrVariants || EMPTY_CSS);
|
|
86
|
+
let actualVariants = variantsParam;
|
|
87
|
+
if (baseStylesOrVariants &&
|
|
88
|
+
"variants" in baseStylesOrVariants &&
|
|
89
|
+
typeof baseStylesOrVariants.variants === "object") {
|
|
90
|
+
actualVariants = baseStylesOrVariants.variants;
|
|
91
|
+
const { compoundVariants: __, variants: _, ...rest } = baseStylesOrVariants;
|
|
92
|
+
actualBaseStyles = rest;
|
|
93
|
+
}
|
|
94
|
+
let baseElementClassName;
|
|
95
|
+
if (typeof defaultElement !== "string" && isStyledComponent(defaultElement)) {
|
|
96
|
+
baseElementClassName = defaultElement.__stoopClassName;
|
|
97
|
+
}
|
|
98
|
+
const StyledComponent = forwardRef(function StyledComponent(propsWithBase, ref) {
|
|
99
|
+
const { as, className, css: cssStyles, ...restProps } = propsWithBase;
|
|
100
|
+
const element = (as || defaultElement);
|
|
101
|
+
const cssObject = useMemo(() => cssStyles && typeof cssStyles === "object" && cssStyles !== null
|
|
102
|
+
? cssStyles
|
|
103
|
+
: EMPTY_CSS, [cssStyles]);
|
|
104
|
+
const { elementProps, variantProps } = extractVariantProps(restProps, actualVariants);
|
|
105
|
+
const contextValue = useContext(themeContext || getDefaultThemeContext());
|
|
106
|
+
const currentTheme = contextValue?.theme || defaultTheme;
|
|
107
|
+
const currentMedia = currentTheme.media ? { ...media, ...currentTheme.media } : media;
|
|
108
|
+
const variantKey = useMemo(() => {
|
|
109
|
+
if (!actualVariants) {
|
|
110
|
+
return "";
|
|
111
|
+
}
|
|
112
|
+
const variantEntries = Object.entries(variantProps);
|
|
113
|
+
if (variantEntries.length === 0) {
|
|
114
|
+
return "";
|
|
115
|
+
}
|
|
116
|
+
return variantEntries
|
|
117
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
118
|
+
.map(([key, value]) => `${key}:${String(value)}`)
|
|
119
|
+
.join("|");
|
|
120
|
+
}, [variantProps]);
|
|
121
|
+
const finalStyles = useMemo(() => {
|
|
122
|
+
let componentStyles = actualBaseStyles;
|
|
123
|
+
if (actualVariants && variantKey) {
|
|
124
|
+
componentStyles = applyVariants(actualVariants, variantProps, actualBaseStyles);
|
|
125
|
+
}
|
|
126
|
+
if (cssObject !== EMPTY_CSS) {
|
|
127
|
+
componentStyles = Object.assign({}, componentStyles, cssObject);
|
|
128
|
+
}
|
|
129
|
+
return componentStyles;
|
|
130
|
+
}, [variantKey, cssObject, actualBaseStyles, actualVariants, variantProps]);
|
|
131
|
+
const finalClassName = useMemo(() => {
|
|
132
|
+
const classNames = [];
|
|
133
|
+
if (baseElementClassName) {
|
|
134
|
+
classNames.push(baseElementClassName);
|
|
135
|
+
}
|
|
136
|
+
const mergedClass = compileCSS(finalStyles, currentTheme, prefix, currentMedia, utils, themeMap);
|
|
137
|
+
if (mergedClass) {
|
|
138
|
+
classNames.push(mergedClass);
|
|
139
|
+
}
|
|
140
|
+
if (className) {
|
|
141
|
+
const classNameStr = typeof className === "string" ? className : String(className);
|
|
142
|
+
const sanitizedClassName = sanitizeClassName(classNameStr);
|
|
143
|
+
if (sanitizedClassName) {
|
|
144
|
+
classNames.push(sanitizedClassName);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return classNames.length > 0 ? classNames.join(" ") : undefined;
|
|
148
|
+
}, [
|
|
149
|
+
finalStyles,
|
|
150
|
+
className,
|
|
151
|
+
baseElementClassName,
|
|
152
|
+
currentTheme,
|
|
153
|
+
prefix,
|
|
154
|
+
currentMedia,
|
|
155
|
+
utils,
|
|
156
|
+
themeMap,
|
|
157
|
+
]);
|
|
158
|
+
return createElement(element, {
|
|
159
|
+
...elementProps,
|
|
160
|
+
className: finalClassName,
|
|
161
|
+
ref,
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
const selectorHash = hash(JSON.stringify(actualBaseStyles));
|
|
165
|
+
const selectorClassName = `${prefix}-${selectorHash}`;
|
|
166
|
+
const componentWithSelector = StyledComponent;
|
|
167
|
+
componentWithSelector.selector = createStyledComponentRef(selectorClassName);
|
|
168
|
+
return componentWithSelector;
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
/**
|
|
3
|
+
* Theme management hook.
|
|
4
|
+
* Provides access to theme state and theme switching functions.
|
|
5
|
+
*/
|
|
6
|
+
import { useContext } from "react";
|
|
7
|
+
/**
|
|
8
|
+
* Creates a useTheme hook for a specific theme management context.
|
|
9
|
+
*
|
|
10
|
+
* @param ThemeManagementContext - React context for theme management
|
|
11
|
+
* @returns Hook function that returns theme management context value
|
|
12
|
+
*/
|
|
13
|
+
export function createUseThemeHook(ThemeManagementContext) {
|
|
14
|
+
return function useTheme() {
|
|
15
|
+
const context = useContext(ThemeManagementContext);
|
|
16
|
+
if (!context) {
|
|
17
|
+
throw new Error("useTheme must be used within a Provider");
|
|
18
|
+
}
|
|
19
|
+
return context;
|
|
20
|
+
};
|
|
21
|
+
}
|
package/dist/constants.d.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared constants used throughout the library.
|
|
3
|
-
* Includes cache size limits
|
|
3
|
+
* Includes cache size limits and nesting depth limits.
|
|
4
4
|
*/
|
|
5
|
-
import type { CSS,
|
|
5
|
+
import type { CSS, ThemeScale } from "./types";
|
|
6
6
|
export declare const EMPTY_CSS: CSS;
|
|
7
7
|
export declare const MAX_CSS_CACHE_SIZE = 10000;
|
|
8
8
|
export declare const MAX_CLASS_NAME_CACHE_SIZE = 5000;
|
|
9
9
|
export declare const MAX_CSS_NESTING_DEPTH = 10;
|
|
10
|
-
export declare const FALLBACK_CONTEXT: import("react").Context<ThemeContextValue>;
|
|
11
10
|
/**
|
|
12
11
|
* Approved theme scales - only these scales are allowed in theme objects.
|
|
13
12
|
*/
|