UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

264 lines (263 loc) 8.43 kB
import { $atom, $module, z } from "alepha"; import { $head } from "alepha/react/head"; import { $cookie } from "alepha/server/cookies"; import { useEffect, useState } from "react"; import { useStore } from "alepha/react"; //#region ../../src/react/ui/atoms/uiThemeListAtom.ts /** * Available themes the user can pick from. Apps populate this atom on boot * (e.g. `alepha.store.set(uiThemeListAtom, MY_THEMES)`); UI consumers like * `<ButtonTheme/>` read it to render a picker. The selected theme id is * persisted separately in `uiAtom.theme`. * * Defaults to a single `"default"` entry so the registry stays usable when * an app doesn't declare its own list. */ const uiThemeListAtom = $atom({ name: "alepha.react.ui.themes", schema: z.array(z.object({ /** Stable id stored in `uiAtom.theme`. Mapped to a CSS class on `<html>`. */ id: z.string(), /** Human-readable label shown in the picker. */ label: z.string(), /** * Optional 4-color preview swatch in 2×2 order (TL, TR, BL, BR). Any * CSS-valid color string. */ swatch: z.array(z.string()).min(4).max(4).optional(), /** * Optional stylesheet URL (typically Google Fonts) loaded lazily when * the theme is selected. */ fontHref: z.string().optional() })), default: [{ id: "default", label: "Default" }] }); //#endregion //#region ../../src/react/ui/atoms/uiAtom.ts /** * Persisted UI state — color mode, theme palette, sidebar collapsed state, etc. * * The atom is bound to a single `alepha-ui` cookie via {@link UiPersistence}, * so values survive page reloads and are available during SSR. */ const uiAtom = $atom({ name: "alepha.react.ui", schema: z.object({ /** Color mode preference. `"system"` follows the OS-level setting. */ mode: z.enum([ "light", "dark", "system" ]), /** Theme palette name. UI consumers map this to a CSS class on the root. */ theme: z.string(), /** Sidebar UI state. */ sidebar: z.object({ collapsed: z.boolean() }) }), default: { mode: "system", theme: "default", sidebar: { collapsed: false } } }); //#endregion //#region ../../src/react/ui/services/UiPersistence.ts /** * Inline `<script>` rendered by SSR into the document `<head>`. Runs * synchronously before any CSS or React hydration: reads the `alepha-ui` * cookie, resolves `mode === "system"` via `prefers-color-scheme`, and * applies `class="dark"` (and optional `theme-<name>`) on `<html>`. * * This is what kills the flash-of-wrong-theme (FOUC) you'd otherwise get * with React-effect-based theming. Failures are swallowed silently — at * worst the page paints in light mode for one frame. */ const colorSchemeBoot = `(function(){try{var m=document.cookie.match(/(?:^|;\\s*)alepha-ui=([^;]+)/);var s=m?JSON.parse(decodeURIComponent(m[1])):{};var mode=s.mode||"system";var dark=mode==="dark"||(mode==="system"&&window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches);var r=document.documentElement;if(dark)r.classList.add("dark");if(s.theme&&s.theme!=="default")r.classList.add("theme-"+s.theme);}catch(e){}})();`; /** * Binds the {@link uiAtom} to an `alepha-ui` cookie + injects an inline * boot script into the SSR head to prevent FOUC on first paint. * * Reading flow: on app boot the cookie is parsed and pushed into the atom * (via the `key` option on `$cookie`). Writing flow: every time the atom * mutates, the cookie is rewritten — a single `useStore(uiAtom)` call is * enough to persist UI preferences across reloads. * * Persists for 365 days; SameSite=lax so it travels on top-level navigation * but not on cross-origin requests. */ var UiPersistence = class { ui = $cookie({ name: "alepha-ui", key: uiAtom.key, schema: uiAtom.schema, ttl: [365, "days"], sameSite: "lax" }); head = $head({ script: [{ content: colorSchemeBoot }] }); }; //#endregion //#region ../../src/react/ui/hooks/useColorMode.ts /** * Read and update the user's color-mode preference. `"system"` resolves to * the OS preference and updates live as the OS toggles between light/dark. * * @example * const { mode, setMode, resolved } = useColorMode(); * setMode("dark"); * document.documentElement.classList.toggle("dark", resolved === "dark"); */ const useColorMode = () => { const [state, set] = useStore(uiAtom); const mode = state?.mode ?? "system"; return { mode, resolved: useResolvedColorMode(mode), setMode: (next) => { set({ ...state ?? uiAtom.options.default, mode: next }); } }; }; const useResolvedColorMode = (mode) => { const [systemDark, setSystemDark] = useState(false); useEffect(() => { if (typeof window === "undefined") return; const mq = window.matchMedia?.("(prefers-color-scheme: dark)"); if (!mq) return; setSystemDark(mq.matches); const onChange = (ev) => setSystemDark(ev.matches); mq.addEventListener("change", onChange); return () => mq.removeEventListener("change", onChange); }, []); if (mode === "dark") return "dark"; if (mode === "light") return "light"; return systemDark ? "dark" : "light"; }; //#endregion //#region ../../src/react/ui/hooks/useTheme.ts /** * Read and update the active theme palette name. UI consumers typically map * the value to a class on the document root (e.g. `theme-claude`). * * @example * const { theme, setTheme } = useTheme(); * setTheme("claude"); */ const useTheme = () => { const [state, set] = useStore(uiAtom); return { theme: state?.theme ?? "default", setTheme: (next) => { set({ ...state ?? uiAtom.options.default, theme: next }); } }; }; //#endregion //#region ../../src/react/ui/components/ColorScheme.tsx /** * Applies `class="dark"` and an optional theme palette class * (`theme-<name>`) to the document root, syncing whenever the underlying * atom mutates. * * Mount once near the root of your tree (typically inside the layout). * * @example * <ColorScheme /> */ function ColorScheme() { const { resolved } = useColorMode(); const { theme } = useTheme(); useEffect(() => { if (typeof document === "undefined") return; document.documentElement.classList.toggle("dark", resolved === "dark"); }, [resolved]); useEffect(() => { if (typeof document === "undefined") return; const root = document.documentElement; const previous = []; root.classList.forEach((cls) => { if (cls.startsWith("theme-")) previous.push(cls); }); for (const cls of previous) root.classList.remove(cls); if (theme && theme !== "default") root.classList.add(`theme-${theme}`); }, [theme]); return null; } //#endregion //#region ../../src/react/ui/hooks/useSidebarState.ts /** * Read and update the sidebar collapsed state. The value is persisted via the * `alepha-ui` cookie so it survives reloads and is available during SSR — no * flash of expanded-then-collapsed when the user prefers a collapsed shell. * * @example * const { collapsed, setCollapsed, toggle } = useSidebarState(); */ const useSidebarState = () => { const [state, set] = useStore(uiAtom); const collapsed = state?.sidebar.collapsed ?? false; const setCollapsed = (next) => { const base = state ?? uiAtom.options.default; set({ ...base, sidebar: { ...base.sidebar, collapsed: next } }); }; return { collapsed, setCollapsed, toggle: () => setCollapsed(!collapsed) }; }; //#endregion //#region ../../src/react/ui/services/SchemaControl.ts /** * Resolve a raw `$control` value (object or function) into a concrete * partial config. Returns `null` when the field should be hidden. */ const resolveSchemaControl = (raw, context) => { if (raw == null) return {}; if (typeof raw === "function") { const result = raw(context); if (result === false) return null; if (!result) return {}; if (result.hidden) return null; return result; } if (typeof raw === "object") { const obj = raw; if (obj.hidden) return null; return obj; } return {}; }; //#endregion //#region ../../src/react/ui/index.ts /** * Persisted UI state: color mode, theme palette, sidebar collapsed state. * * Backed by an `alepha-ui` cookie so preferences survive reloads and are * available during SSR (no flash of wrong theme). * * @module alepha.react.ui */ const AlephaReactUi = $module({ name: "alepha.react.ui", atoms: [uiThemeListAtom], services: [UiPersistence] }); //#endregion export { AlephaReactUi, ColorScheme, UiPersistence, resolveSchemaControl, uiAtom, uiThemeListAtom, useColorMode, useSidebarState, useTheme }; //# sourceMappingURL=index.js.map