mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-05 09:36:37 +08:00
feat: implement theme selection and system integration
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Theme Context + Provider (React integration)
|
||||
* =============================================
|
||||
* Wraps the registry in a React context so that the entire component tree
|
||||
* re-renders when the active theme changes.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useSyncExternalStore,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
import type { ThemePackage, ComponentKey, LayoutKey, PageKey } from "./types";
|
||||
import {
|
||||
subscribe,
|
||||
getActiveTheme,
|
||||
getActiveThemeId,
|
||||
getRegisteredThemes,
|
||||
activateTheme,
|
||||
deactivateTheme,
|
||||
reapplyActiveTheme,
|
||||
resolveComponent,
|
||||
resolveLayout,
|
||||
resolvePage,
|
||||
getSavedMode,
|
||||
saveMode,
|
||||
getEffectiveMode,
|
||||
initThemeSystem,
|
||||
registerTheme,
|
||||
unregisterTheme,
|
||||
type ThemeMode,
|
||||
} from "./registry";
|
||||
|
||||
// ─── context value ───────────────────────────────────────────────────────────
|
||||
|
||||
interface ThemeContextValue {
|
||||
/** Currently active theme package (null = default). */
|
||||
activeTheme: ThemePackage | null;
|
||||
activeThemeId: string | null;
|
||||
/** All registered themes. */
|
||||
themes: ThemePackage[];
|
||||
/** Current mode preference. */
|
||||
mode: ThemeMode;
|
||||
/** Resolved effective mode (never "system"). */
|
||||
effectiveMode: "light" | "dark";
|
||||
|
||||
/** Switch the active theme. */
|
||||
switchTheme: (id: string) => void;
|
||||
/** Reset to no custom theme (use defaults). */
|
||||
resetTheme: () => void;
|
||||
/** Change mode preference. */
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
/** Register a new theme at runtime. */
|
||||
register: (pkg: ThemePackage) => void;
|
||||
/** Unregister a theme by id. */
|
||||
unregister: (id: string) => void;
|
||||
|
||||
/** Resolve a component (returns themed override or fallback). */
|
||||
component: <P = any>(key: ComponentKey, fallback: React.ComponentType<P>) => React.ComponentType<P>;
|
||||
/** Resolve a layout. */
|
||||
layout: (key: LayoutKey, fallback: React.ComponentType<{ children: React.ReactNode }>) => React.ComponentType<{ children: React.ReactNode }>;
|
||||
/** Resolve a page. */
|
||||
page: <P = any>(key: PageKey, fallback: React.ComponentType<P>) => React.ComponentType<P>;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
// ─── snapshot for useSyncExternalStore ────────────────────────────────────────
|
||||
|
||||
// We use a monotonic counter to create new snapshot references when the
|
||||
// registry notifies.
|
||||
let snapshotCounter = 0;
|
||||
function getSnapshot() {
|
||||
return snapshotCounter;
|
||||
}
|
||||
const originalSubscribe = (onStoreChange: () => void) => {
|
||||
const unsub = subscribe(() => {
|
||||
snapshotCounter++;
|
||||
onStoreChange();
|
||||
});
|
||||
return unsub;
|
||||
};
|
||||
|
||||
// ─── provider ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
// Re-render whenever register changes
|
||||
useSyncExternalStore(originalSubscribe, getSnapshot);
|
||||
|
||||
// Initialise on mount
|
||||
useEffect(() => {
|
||||
initThemeSystem();
|
||||
}, []);
|
||||
|
||||
// Listen for system mode changes
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = () => reapplyActiveTheme();
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
const switchTheme = useCallback((id: string) => activateTheme(id), []);
|
||||
const resetTheme = useCallback(() => {
|
||||
deactivateTheme();
|
||||
reapplyActiveTheme();
|
||||
localStorage.removeItem("flvx:active-theme");
|
||||
}, []);
|
||||
const setMode = useCallback((m: ThemeMode) => {
|
||||
saveMode(m);
|
||||
reapplyActiveTheme();
|
||||
}, []);
|
||||
const register = useCallback((pkg: ThemePackage) => registerTheme(pkg), []);
|
||||
const unregister = useCallback((id: string) => unregisterTheme(id), []);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(() => ({
|
||||
activeTheme: getActiveTheme(),
|
||||
activeThemeId: getActiveThemeId(),
|
||||
themes: getRegisteredThemes(),
|
||||
mode: getSavedMode(),
|
||||
effectiveMode: getEffectiveMode(),
|
||||
switchTheme,
|
||||
resetTheme,
|
||||
setMode,
|
||||
register,
|
||||
unregister,
|
||||
component: resolveComponent,
|
||||
layout: resolveLayout,
|
||||
page: resolvePage,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}), [snapshotCounter, switchTheme, resetTheme, setMode, register, unregister]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── hooks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Access the full theme context. */
|
||||
export function useThemeContext(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useThemeContext must be used within <ThemeProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: resolve a single themed component.
|
||||
*
|
||||
* ```tsx
|
||||
* import { useThemedComponent } from "@/themes/context";
|
||||
* import { Button as DefaultButton } from "@/shadcn-bridge/heroui/button";
|
||||
*
|
||||
* function MyPage() {
|
||||
* const Button = useThemedComponent("Button", DefaultButton);
|
||||
* return <Button color="primary">Click</Button>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useThemedComponent<P = any>(
|
||||
key: ComponentKey,
|
||||
fallback: React.ComponentType<P>,
|
||||
): React.ComponentType<P> {
|
||||
const ctx = useThemeContext();
|
||||
return ctx.component(key, fallback);
|
||||
}
|
||||
|
||||
/** Convenience: resolve a themed layout. */
|
||||
export function useThemedLayout(
|
||||
key: LayoutKey,
|
||||
fallback: React.ComponentType<{ children: React.ReactNode }>,
|
||||
): React.ComponentType<{ children: React.ReactNode }> {
|
||||
const ctx = useThemeContext();
|
||||
return ctx.layout(key, fallback);
|
||||
}
|
||||
|
||||
/** Convenience: resolve a themed page. */
|
||||
export function useThemedPage<P = any>(
|
||||
key: PageKey,
|
||||
fallback: React.ComponentType<P>,
|
||||
): React.ComponentType<P> {
|
||||
const ctx = useThemeContext();
|
||||
return ctx.page(key, fallback);
|
||||
}
|
||||
Reference in New Issue
Block a user