From e1637f59b818ad1bb1c0d9197b10d406fbf09147 Mon Sep 17 00:00:00 2001 From: KevinMB0220 Date: Thu, 22 Jan 2026 19:57:29 -0600 Subject: [PATCH 1/3] feat(ui): add initial ui-components library --- libs/ui-components/package-lock.json | 92 ++++++++ libs/ui-components/package.json | 6 + .../TransactionContext.tsx | 104 +++++++++ .../TransactionHeartbeat.headless.tsx | 83 +++++++ .../TransactionHeartbeat.tsx | 212 ++++++++++++++++++ .../components/TransactionHeartbeat/index.ts | 12 + .../src/components/headless/index.ts | 10 + libs/ui-components/src/index.ts | 42 ++++ libs/ui-components/src/styles/globals.css | 170 ++++++++++++++ .../ui-components/src/theme/ThemeProvider.tsx | 194 ++++++++++++++++ libs/ui-components/src/theme/ThemeScript.tsx | 80 +++++++ .../ui-components/src/theme/hooks/useTheme.ts | 6 + libs/ui-components/src/theme/index.ts | 23 ++ libs/ui-components/src/theme/tokens/dark.ts | 46 ++++ .../src/theme/tokens/defaults.ts | 100 +++++++++ libs/ui-components/src/theme/tokens/index.ts | 8 + .../src/theme/tokens/primitives.ts | 79 +++++++ libs/ui-components/src/theme/types.ts | 200 +++++++++++++++++ .../ui-components/src/theme/utils/css-vars.ts | 62 +++++ libs/ui-components/src/theme/utils/index.ts | 7 + .../src/theme/utils/merge-theme.ts | 49 ++++ 21 files changed, 1585 insertions(+) create mode 100644 libs/ui-components/package-lock.json create mode 100644 libs/ui-components/src/components/TransactionHeartbeat/TransactionContext.tsx create mode 100644 libs/ui-components/src/components/TransactionHeartbeat/TransactionHeartbeat.headless.tsx create mode 100644 libs/ui-components/src/components/TransactionHeartbeat/TransactionHeartbeat.tsx create mode 100644 libs/ui-components/src/components/TransactionHeartbeat/index.ts create mode 100644 libs/ui-components/src/components/headless/index.ts create mode 100644 libs/ui-components/src/index.ts create mode 100644 libs/ui-components/src/styles/globals.css create mode 100644 libs/ui-components/src/theme/ThemeProvider.tsx create mode 100644 libs/ui-components/src/theme/ThemeScript.tsx create mode 100644 libs/ui-components/src/theme/hooks/useTheme.ts create mode 100644 libs/ui-components/src/theme/index.ts create mode 100644 libs/ui-components/src/theme/tokens/dark.ts create mode 100644 libs/ui-components/src/theme/tokens/defaults.ts create mode 100644 libs/ui-components/src/theme/tokens/index.ts create mode 100644 libs/ui-components/src/theme/tokens/primitives.ts create mode 100644 libs/ui-components/src/theme/types.ts create mode 100644 libs/ui-components/src/theme/utils/css-vars.ts create mode 100644 libs/ui-components/src/theme/utils/index.ts create mode 100644 libs/ui-components/src/theme/utils/merge-theme.ts diff --git a/libs/ui-components/package-lock.json b/libs/ui-components/package-lock.json new file mode 100644 index 0000000..6efef90 --- /dev/null +++ b/libs/ui-components/package-lock.json @@ -0,0 +1,92 @@ +{ + "name": "@bridgewise/ui-components", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@bridgewise/ui-components", + "version": "0.1.0", + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.0.0" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.9", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", + "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/libs/ui-components/package.json b/libs/ui-components/package.json index e27ed2f..0bd8bad 100644 --- a/libs/ui-components/package.json +++ b/libs/ui-components/package.json @@ -2,6 +2,12 @@ "name": "@bridgewise/ui-components", "version": "0.1.0", "main": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./theme": "./src/theme/index.ts", + "./headless": "./src/components/headless/index.ts", + "./styles/globals.css": "./src/styles/globals.css" + }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/libs/ui-components/src/components/TransactionHeartbeat/TransactionContext.tsx b/libs/ui-components/src/components/TransactionHeartbeat/TransactionContext.tsx new file mode 100644 index 0000000..c5fe48f --- /dev/null +++ b/libs/ui-components/src/components/TransactionHeartbeat/TransactionContext.tsx @@ -0,0 +1,104 @@ +/** + * Transaction Context + * Manages transaction state across the application + */ + +'use client'; + +import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; + +export interface TransactionState { + id: string; + status: 'idle' | 'pending' | 'success' | 'failed'; + progress: number; + step: string; + txHash?: string; + timestamp: number; +} + +interface TransactionContextType { + state: TransactionState; + updateState: (updates: Partial) => void; + clearState: () => void; + startTransaction: (id: string) => void; +} + +const TransactionContext = createContext(undefined); + +const STORAGE_KEY = 'bridgewise_tx_state'; + +export const TransactionProvider = ({ children }: { children: ReactNode }) => { + const [state, setState] = useState({ + id: '', + status: 'idle', + progress: 0, + step: '', + timestamp: 0, + }); + + // Load from storage on mount + useEffect(() => { + if (typeof window === 'undefined') return; + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + // Expiry check (24 hours) + if (Date.now() - parsed.timestamp < 24 * 60 * 60 * 1000) { + setState(parsed); + } else { + localStorage.removeItem(STORAGE_KEY); + } + } + } catch (e) { + console.error('Failed to load transaction state', e); + } + }, []); + + // Save to storage on change + useEffect(() => { + if (typeof window === 'undefined') return; + if (state.status !== 'idle') { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } + }, [state]); + + const updateState = useCallback((updates: Partial) => { + setState((prev) => ({ ...prev, ...updates, timestamp: Date.now() })); + }, []); + + const clearState = useCallback(() => { + setState({ + id: '', + status: 'idle', + progress: 0, + step: '', + timestamp: 0, + }); + localStorage.removeItem(STORAGE_KEY); + }, []); + + const startTransaction = useCallback((id: string) => { + setState({ + id, + status: 'pending', + progress: 0, + step: 'Initializing...', + timestamp: Date.now() + }); + }, []); + + return ( + + {children} + + ); +}; + +export const useTransaction = () => { + const context = useContext(TransactionContext); + if (!context) { + throw new Error('useTransaction must be used within a TransactionProvider'); + } + return context; +}; diff --git a/libs/ui-components/src/components/TransactionHeartbeat/TransactionHeartbeat.headless.tsx b/libs/ui-components/src/components/TransactionHeartbeat/TransactionHeartbeat.headless.tsx new file mode 100644 index 0000000..b646661 --- /dev/null +++ b/libs/ui-components/src/components/TransactionHeartbeat/TransactionHeartbeat.headless.tsx @@ -0,0 +1,83 @@ +/** + * TransactionHeartbeat Headless Component + * Provides transaction state and logic without any styling + * Uses render props pattern for maximum flexibility + */ + +'use client'; + +import React from 'react'; +import { useTransaction } from './TransactionContext'; +import type { TransactionState } from './TransactionContext'; + +export interface TransactionHeartbeatRenderProps { + /** Full transaction state object */ + state: TransactionState; + /** Function to clear/dismiss the transaction state */ + clearState: () => void; + /** Update transaction state with partial updates */ + updateState: (updates: Partial) => void; + /** Start a new transaction */ + startTransaction: (id: string) => void; + /** Convenience boolean: true if status is 'success' */ + isSuccess: boolean; + /** Convenience boolean: true if status is 'failed' */ + isFailed: boolean; + /** Convenience boolean: true if status is 'pending' */ + isPending: boolean; +} + +export interface TransactionHeartbeatHeadlessProps { + /** + * Render function that receives transaction state and controls + * Return null to hide the component + */ + children: (props: TransactionHeartbeatRenderProps) => React.ReactNode; +} + +/** + * Headless transaction heartbeat component + * + * @example + * ```tsx + * + * {({ state, clearState, isSuccess, isPending }) => ( + *
+ *

{isSuccess ? '✅ Done!' : isPending ? '⏳ Processing...' : '❌ Failed'}

+ * + *

{state.step}

+ * + *
+ * )} + *
+ * ``` + */ +export const TransactionHeartbeatHeadless: React.FC = ({ + children, +}) => { + const { state, clearState, updateState, startTransaction } = useTransaction(); + + // Don't render if transaction is idle + if (state.status === 'idle') { + return null; + } + + // Derive convenience booleans from state + const isSuccess = state.status === 'success'; + const isFailed = state.status === 'failed'; + const isPending = state.status === 'pending'; + + return ( + <> + {children({ + state, + clearState, + updateState, + startTransaction, + isSuccess, + isFailed, + isPending, + })} + + ); +}; diff --git a/libs/ui-components/src/components/TransactionHeartbeat/TransactionHeartbeat.tsx b/libs/ui-components/src/components/TransactionHeartbeat/TransactionHeartbeat.tsx new file mode 100644 index 0000000..12dc512 --- /dev/null +++ b/libs/ui-components/src/components/TransactionHeartbeat/TransactionHeartbeat.tsx @@ -0,0 +1,212 @@ +/** + * TransactionHeartbeat Component + * Styled notification component for transaction status + * Uses BridgeWise theme CSS variables for styling + */ + +'use client'; + +import React from 'react'; +import { TransactionHeartbeatHeadless } from './TransactionHeartbeat.headless'; + +/** + * Transaction heartbeat notification component + * Displays transaction progress with themed styling + * + * @example + * ```tsx + * import { TransactionProvider, TransactionHeartbeat } from '@bridgewise/ui-components'; + * + * function App() { + * return ( + * + * + * + * + * ); + * } + * ``` + */ +export const TransactionHeartbeat: React.FC = () => { + return ( + + {({ state, clearState, isSuccess, isFailed, isPending }) => ( +
+
+ {/* Header */} +
+

+ {isSuccess + ? 'Transaction Complete' + : isFailed + ? 'Transaction Failed' + : 'Bridging Assets...'} +

+ +
+ + {/* Progress bar */} +
+
+
+
+
+
+ + {state.progress}% + +
+ + {/* Status text */} +

+ {state.step} +

+ + {/* Transaction hash link */} + {state.txHash && ( + + )} +
+ + {/* Status indicator bar at bottom */} +
+ + {/* Pulse animation keyframes */} + +
+ )} + + ); +}; diff --git a/libs/ui-components/src/components/TransactionHeartbeat/index.ts b/libs/ui-components/src/components/TransactionHeartbeat/index.ts new file mode 100644 index 0000000..3f1196d --- /dev/null +++ b/libs/ui-components/src/components/TransactionHeartbeat/index.ts @@ -0,0 +1,12 @@ +/** + * TransactionHeartbeat Component Exports + */ + +export { TransactionHeartbeat } from './TransactionHeartbeat'; +export { TransactionHeartbeatHeadless } from './TransactionHeartbeat.headless'; +export { TransactionProvider, useTransaction } from './TransactionContext'; +export type { TransactionState } from './TransactionContext'; +export type { + TransactionHeartbeatHeadlessProps, + TransactionHeartbeatRenderProps, +} from './TransactionHeartbeat.headless'; diff --git a/libs/ui-components/src/components/headless/index.ts b/libs/ui-components/src/components/headless/index.ts new file mode 100644 index 0000000..bd242ba --- /dev/null +++ b/libs/ui-components/src/components/headless/index.ts @@ -0,0 +1,10 @@ +/** + * Headless Components Exports + * Unstyled components for maximum customization + */ + +export { TransactionHeartbeatHeadless } from '../TransactionHeartbeat/TransactionHeartbeat.headless'; +export type { + TransactionHeartbeatHeadlessProps, + TransactionHeartbeatRenderProps, +} from '../TransactionHeartbeat/TransactionHeartbeat.headless'; diff --git a/libs/ui-components/src/index.ts b/libs/ui-components/src/index.ts new file mode 100644 index 0000000..c044b38 --- /dev/null +++ b/libs/ui-components/src/index.ts @@ -0,0 +1,42 @@ +/** + * BridgeWise UI Components + * Main entry point for the component library + */ + +// Theme System +export { + ThemeProvider, + useTheme, + ThemeScript, + defaultTheme, + darkTheme, + primitiveColors, + mergeTheme, + generateCSSVariables, +} from './theme'; + +export type { + Theme, + ThemeMode, + ThemeColors, + ThemeSpacing, + ThemeTypography, + ThemeShadows, + ThemeRadii, + ThemeTransitions, + ThemeContextValue, + DeepPartial, + ThemeConfig, + CSSVariables, +} from './theme'; + +// Components +export { + TransactionHeartbeat, + TransactionProvider, + useTransaction, +} from './components/TransactionHeartbeat'; + +export type { + TransactionState, +} from './components/TransactionHeartbeat'; diff --git a/libs/ui-components/src/styles/globals.css b/libs/ui-components/src/styles/globals.css new file mode 100644 index 0000000..2a8db69 --- /dev/null +++ b/libs/ui-components/src/styles/globals.css @@ -0,0 +1,170 @@ +/** + * BridgeWise UI Components Global Styles + * Theme system base styles and CSS variables + * + * Note: This file does NOT import Tailwind CSS. + * The consuming application should import Tailwind separately. + */ + +/** + * CSS Variable Fallbacks + * These provide defaults before JavaScript loads the theme + */ +:root { + /* Colors - Light mode fallbacks */ + --bw-colors-background-primary: #ffffff; + --bw-colors-background-secondary: #f8fafc; + --bw-colors-background-tertiary: #f1f5f9; + --bw-colors-foreground-primary: #0f172a; + --bw-colors-foreground-secondary: #475569; + --bw-colors-foreground-tertiary: #94a3b8; + --bw-colors-foreground-link: #2563eb; + + /* Borders */ + --bw-colors-border-default: #e2e8f0; + --bw-colors-border-focus: #3b82f6; + --bw-colors-border-error: #ef4444; + + /* Status */ + --bw-colors-status-success: #22c55e; + --bw-colors-status-error: #ef4444; + --bw-colors-status-warning: #eab308; + --bw-colors-status-info: #3b82f6; + --bw-colors-status-pending: #2563eb; + + /* Transaction component */ + --bw-colors-transaction-background: #ffffff; + --bw-colors-transaction-border: #e2e8f0; + --bw-colors-transaction-progress-bar-success: #22c55e; + --bw-colors-transaction-progress-bar-error: #ef4444; + --bw-colors-transaction-progress-bar-pending: #2563eb; + + /* Spacing */ + --bw-spacing-xs: 0.25rem; + --bw-spacing-sm: 0.5rem; + --bw-spacing-md: 1rem; + --bw-spacing-lg: 1.5rem; + --bw-spacing-xl: 2rem; + --bw-spacing-2xl: 3rem; + + /* Typography */ + --bw-typography-font-family-sans: var(--font-geist-sans, system-ui, sans-serif); + --bw-typography-font-family-mono: var(--font-geist-mono, ui-monospace, monospace); + --bw-typography-font-size-xs: 0.75rem; + --bw-typography-font-size-sm: 0.875rem; + --bw-typography-font-size-base: 1rem; + --bw-typography-font-size-lg: 1.125rem; + --bw-typography-font-size-xl: 1.25rem; + --bw-typography-font-size-2xl: 1.5rem; + --bw-typography-font-weight-normal: 400; + --bw-typography-font-weight-medium: 500; + --bw-typography-font-weight-semibold: 600; + --bw-typography-font-weight-bold: 700; + --bw-typography-line-height-tight: 1.25; + --bw-typography-line-height-normal: 1.5; + --bw-typography-line-height-relaxed: 1.75; + + /* Shadows */ + --bw-shadows-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --bw-shadows-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --bw-shadows-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --bw-shadows-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + + /* Radii */ + --bw-radii-none: 0; + --bw-radii-sm: 0.125rem; + --bw-radii-md: 0.375rem; + --bw-radii-lg: 0.5rem; + --bw-radii-xl: 0.75rem; + --bw-radii-full: 9999px; + + /* Transitions */ + --bw-transitions-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --bw-transitions-base: 300ms cubic-bezier(0.4, 0, 0.2, 1); + --bw-transitions-slow: 500ms cubic-bezier(0.4, 0, 0.2, 1); +} + +/** + * Dark Mode Fallbacks + * Applied before JavaScript loads when system prefers dark + */ +@media (prefers-color-scheme: dark) { + :root { + /* Colors - Dark mode fallbacks */ + --bw-colors-background-primary: #0f172a; + --bw-colors-background-secondary: #1e293b; + --bw-colors-background-tertiary: #334155; + --bw-colors-foreground-primary: #f8fafc; + --bw-colors-foreground-secondary: #94a3b8; + --bw-colors-foreground-tertiary: #64748b; + --bw-colors-foreground-link: #60a5fa; + + /* Borders */ + --bw-colors-border-default: #334155; + --bw-colors-border-focus: #60a5fa; + --bw-colors-border-error: #f87171; + + /* Status */ + --bw-colors-status-success: #4ade80; + --bw-colors-status-error: #f87171; + --bw-colors-status-warning: #facc15; + --bw-colors-status-info: #60a5fa; + --bw-colors-status-pending: #3b82f6; + + /* Transaction component */ + --bw-colors-transaction-background: #1e293b; + --bw-colors-transaction-border: #334155; + } +} + +/** + * Tailwind v4 Theme Integration + * Maps CSS variables to Tailwind utilities using @theme inline directive + */ +@theme inline { + /* Colors */ + --color-bw-bg-primary: var(--bw-colors-background-primary); + --color-bw-bg-secondary: var(--bw-colors-background-secondary); + --color-bw-bg-tertiary: var(--bw-colors-background-tertiary); + --color-bw-fg-primary: var(--bw-colors-foreground-primary); + --color-bw-fg-secondary: var(--bw-colors-foreground-secondary); + --color-bw-fg-tertiary: var(--bw-colors-foreground-tertiary); + --color-bw-fg-link: var(--bw-colors-foreground-link); + --color-bw-border: var(--bw-colors-border-default); + --color-bw-border-focus: var(--bw-colors-border-focus); + --color-bw-success: var(--bw-colors-status-success); + --color-bw-error: var(--bw-colors-status-error); + --color-bw-warning: var(--bw-colors-status-warning); + --color-bw-info: var(--bw-colors-status-info); + + /* Spacing */ + --spacing-bw-xs: var(--bw-spacing-xs); + --spacing-bw-sm: var(--bw-spacing-sm); + --spacing-bw-md: var(--bw-spacing-md); + --spacing-bw-lg: var(--bw-spacing-lg); + --spacing-bw-xl: var(--bw-spacing-xl); + --spacing-bw-2xl: var(--bw-spacing-2xl); + + /* Typography */ + --font-bw-sans: var(--bw-typography-font-family-sans); + --font-bw-mono: var(--bw-typography-font-family-mono); + --text-bw-xs: var(--bw-typography-font-size-xs); + --text-bw-sm: var(--bw-typography-font-size-sm); + --text-bw-base: var(--bw-typography-font-size-base); + --text-bw-lg: var(--bw-typography-font-size-lg); + --text-bw-xl: var(--bw-typography-font-size-xl); + --text-bw-2xl: var(--bw-typography-font-size-2xl); + + /* Shadows */ + --shadow-bw-sm: var(--bw-shadows-sm); + --shadow-bw-md: var(--bw-shadows-md); + --shadow-bw-lg: var(--bw-shadows-lg); + --shadow-bw-xl: var(--bw-shadows-xl); + + /* Radii */ + --radius-bw-sm: var(--bw-radii-sm); + --radius-bw-md: var(--bw-radii-md); + --radius-bw-lg: var(--bw-radii-lg); + --radius-bw-xl: var(--bw-radii-xl); + --radius-bw-full: var(--bw-radii-full); +} diff --git a/libs/ui-components/src/theme/ThemeProvider.tsx b/libs/ui-components/src/theme/ThemeProvider.tsx new file mode 100644 index 0000000..efe44d9 --- /dev/null +++ b/libs/ui-components/src/theme/ThemeProvider.tsx @@ -0,0 +1,194 @@ +/** + * ThemeProvider Component + * Core provider for BridgeWise theming system + * Manages theme state, dark mode, and CSS variable injection + */ + +'use client'; + +import React, { createContext, useContext, useEffect, useState, useMemo, useCallback } from 'react'; +import { mergeTheme } from './utils/merge-theme'; +import { generateCSSVariables } from './utils/css-vars'; +import { defaultTheme, darkTheme } from './tokens'; +import type { Theme, ThemeMode, ThemeContextValue, DeepPartial } from './types'; + +const ThemeContext = createContext(undefined); + +export interface ThemeProviderProps { + children: React.ReactNode; + /** + * Custom theme configuration to merge with defaults + */ + theme?: DeepPartial; + /** + * Initial theme mode (light/dark/system) + * @default 'system' + */ + defaultMode?: ThemeMode; + /** + * Storage key for persisting theme preference + * @default 'bridgewise-theme-mode' + */ + storageKey?: string; + /** + * Enable/disable system theme detection + * @default true + */ + enableSystem?: boolean; + /** + * Disable all transitions during theme switch + * @default true + */ + disableTransitionOnChange?: boolean; + /** + * Custom attribute to set on root element + * @default 'data-theme' + */ + attribute?: 'class' | 'data-theme'; + /** + * Value to set for dark mode + * @default 'dark' + */ + darkValue?: string; + /** + * Value to set for light mode + * @default 'light' + */ + lightValue?: string; +} + +export const ThemeProvider: React.FC = ({ + children, + theme: customTheme, + defaultMode = 'system', + storageKey = 'bridgewise-theme-mode', + enableSystem = true, + disableTransitionOnChange = true, + attribute = 'data-theme', + darkValue = 'dark', + lightValue = 'light', +}) => { + const [mode, setModeState] = useState(defaultMode); + const [resolvedMode, setResolvedMode] = useState<'light' | 'dark'>('light'); + const [mounted, setMounted] = useState(false); + + // Merge custom theme with defaults + const mergedTheme = useMemo(() => { + const baseTheme = customTheme ? mergeTheme(defaultTheme, customTheme) : defaultTheme; + const themeWithMode = resolvedMode === 'dark' + ? mergeTheme(baseTheme, darkTheme) + : baseTheme; + return themeWithMode; + }, [customTheme, resolvedMode]); + + // Initialize theme from storage/system on mount + useEffect(() => { + setMounted(true); + + // Load from storage + if (typeof window !== 'undefined') { + const stored = localStorage.getItem(storageKey); + if (stored && ['light', 'dark', 'system'].includes(stored)) { + setModeState(stored as ThemeMode); + } + } + }, [storageKey]); + + // Handle system preference changes + useEffect(() => { + if (!enableSystem || !mounted) return; + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + const updateResolvedMode = () => { + if (mode === 'system') { + setResolvedMode(mediaQuery.matches ? 'dark' : 'light'); + } else { + setResolvedMode(mode as 'light' | 'dark'); + } + }; + + updateResolvedMode(); + + // Listen for system changes + mediaQuery.addEventListener('change', updateResolvedMode); + return () => mediaQuery.removeEventListener('change', updateResolvedMode); + }, [mode, enableSystem, mounted]); + + // Apply theme to DOM + useEffect(() => { + if (!mounted) return; + + const root = document.documentElement; + + // Disable transitions temporarily + if (disableTransitionOnChange) { + const css = document.createElement('style'); + css.textContent = '* { transition: none !important; }'; + document.head.appendChild(css); + + // Force reflow + requestAnimationFrame(() => { + requestAnimationFrame(() => { + document.head.removeChild(css); + }); + }); + } + + // Set theme attribute + if (attribute === 'class') { + root.classList.remove(lightValue, darkValue); + root.classList.add(resolvedMode === 'dark' ? darkValue : lightValue); + } else { + root.setAttribute(attribute, resolvedMode === 'dark' ? darkValue : lightValue); + } + + // Generate and inject CSS variables + const cssVars = generateCSSVariables(mergedTheme); + Object.entries(cssVars).forEach(([key, value]) => { + root.style.setProperty(key, value); + }); + + }, [resolvedMode, mergedTheme, mounted, attribute, darkValue, lightValue, disableTransitionOnChange]); + + const setMode = useCallback((newMode: ThemeMode) => { + setModeState(newMode); + if (typeof window !== 'undefined') { + localStorage.setItem(storageKey, newMode); + } + }, [storageKey]); + + const toggleMode = useCallback(() => { + setMode(mode === 'light' ? 'dark' : mode === 'dark' ? 'light' : 'light'); + }, [mode, setMode]); + + const value: ThemeContextValue = useMemo(() => ({ + theme: mergedTheme, + mode: resolvedMode, + setMode, + toggleMode, + }), [mergedTheme, resolvedMode, setMode, toggleMode]); + + // Prevent flash on SSR + if (!mounted) { + return <>{children}; + } + + return ( + + {children} + + ); +}; + +/** + * Hook to access theme context + * @throws Error if used outside ThemeProvider + */ +export const useTheme = () => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}; diff --git a/libs/ui-components/src/theme/ThemeScript.tsx b/libs/ui-components/src/theme/ThemeScript.tsx new file mode 100644 index 0000000..872956c --- /dev/null +++ b/libs/ui-components/src/theme/ThemeScript.tsx @@ -0,0 +1,80 @@ +/** + * ThemeScript Component + * Inline script that runs before React hydration to prevent flash of wrong theme + * This should be placed in the of your document + */ + +import React from 'react'; + +export interface ThemeScriptProps { + /** + * Storage key for theme preference + * @default 'bridgewise-theme-mode' + */ + storageKey?: string; + /** + * Attribute to set on root element + * @default 'data-theme' + */ + attribute?: 'class' | 'data-theme'; + /** + * Default theme mode + * @default 'system' + */ + defaultMode?: 'light' | 'dark' | 'system'; + /** + * Value to use for dark mode + * @default 'dark' + */ + darkValue?: string; + /** + * Value to use for light mode + * @default 'light' + */ + lightValue?: string; +} + +export const ThemeScript: React.FC = ({ + storageKey = 'bridgewise-theme-mode', + attribute = 'data-theme', + defaultMode = 'system', + darkValue = 'dark', + lightValue = 'light', +}) => { + // Inline script that runs synchronously before React hydration + const script = ` +(function() { + try { + var storageKey = '${storageKey}'; + var attribute = '${attribute}'; + var defaultMode = '${defaultMode}'; + var darkValue = '${darkValue}'; + var lightValue = '${lightValue}'; + + var stored = localStorage.getItem(storageKey); + var mode = stored || defaultMode; + + var resolvedMode = mode === 'system' + ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') + : mode; + + var value = resolvedMode === 'dark' ? darkValue : lightValue; + + if (attribute === 'class') { + document.documentElement.classList.add(value); + } else { + document.documentElement.setAttribute(attribute, value); + } + } catch (e) { + // Fail silently in case of errors + } +})(); + `.trim(); + + return ( +