diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx index e232f0b..c525cd6 100644 --- a/src/components/ui/alert-dialog.tsx +++ b/src/components/ui/alert-dialog.tsx @@ -1,9 +1,11 @@ +"use client"; + import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; import * as React from "react"; import { cn } from "@/lib/utils"; import { Button } from "./button"; -import { useThemeOptional, defaultCssVariables } from "@/providers"; +import { useThemeOptional } from "@/providers"; function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { return ; @@ -15,9 +17,14 @@ function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { ); } -function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { +function AlertDialogPortal({ + children, + ...props +}: AlertDialogPrimitive.Portal.Props) { return ( - + + {children} + ); } @@ -40,25 +47,30 @@ function AlertDialogOverlay({ function AlertDialogContent({ className, size = "default", + children, ...props }: AlertDialogPrimitive.Popup.Props & { size?: "default" | "sm"; }) { - const theme = useThemeOptional(); - const mode = theme?.mode ?? 'light'; - const cssVariables = theme?.cssVariables ?? defaultCssVariables; + const { resolvedMode, cssVariables, className: themeClassName } = useThemeOptional(); + return ( - - - + +
+ + + + {children} + +
); } diff --git a/src/components/ui/combobox.tsx b/src/components/ui/combobox.tsx index 58ab06f..5d93f7a 100644 --- a/src/components/ui/combobox.tsx +++ b/src/components/ui/combobox.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Combobox as ComboboxPrimitive } from "@base-ui/react"; import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"; import * as React from "react"; @@ -5,7 +7,7 @@ import * as React from "react"; import { cn } from "@/lib/utils"; import { Button } from "./button"; import { Input } from "./input"; -import { useThemeOptional, defaultCssVariables } from "@/providers"; +import { useThemeOptional } from "@/providers"; const Combobox = ComboboxPrimitive.Root; @@ -103,6 +105,17 @@ const ComboboxInput = React.forwardRef< ); ComboboxInput.displayName = "ComboboxInput"; +function ComboboxPortal({ + children, + ...props +}: ComboboxPrimitive.Portal.Props) { + return ( + + {children} + + ); +} + function ComboboxContent({ className, side = "bottom", @@ -116,30 +129,30 @@ function ComboboxContent({ ComboboxPrimitive.Positioner.Props, "side" | "align" | "sideOffset" | "alignOffset" | "anchor" >) { - const theme = useThemeOptional(); - const mode = theme?.mode ?? 'light'; - const cssVariables = theme?.cssVariables ?? defaultCssVariables; + const { resolvedMode, cssVariables, className: themeClassName } = useThemeOptional(); return ( - - - - - + +
+ + + +
+
); } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 88a2dc0..bf4d15a 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -6,7 +6,7 @@ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { XIcon } from "lucide-react" -import { useThemeOptional, defaultCssVariables } from "@/providers" +import { useThemeOptional } from "@/providers" function Dialog({ ...props }: DialogPrimitive.Root.Props) { return @@ -20,8 +20,15 @@ function DialogClose({ ...props }: DialogPrimitive.Close.Props) { return } -function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { - return +function DialogPortal({ + children, + ...props +}: DialogPrimitive.Portal.Props) { + return ( + + {children} + + ) } function DialogOverlay({ @@ -48,37 +55,37 @@ function DialogContent({ }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean }) { - const theme = useThemeOptional() - const mode = theme?.mode ?? "light" - const cssVariables = theme?.cssVariables ?? defaultCssVariables + const { resolvedMode, cssVariables, className: themeClassName } = useThemeOptional() return ( - - - - {children} - {showCloseButton && ( - - } - > - - Close - - )} - + +
+ + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + +
) } diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index ec6214e..b1f587f 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -1,16 +1,25 @@ +"use client"; + import { Menu as MenuPrimitive } from "@base-ui/react/menu"; import { CheckIcon, ChevronRightIcon } from "lucide-react"; import * as React from "react"; import { cn } from "@/lib/utils"; -import { useThemeOptional, defaultCssVariables } from "@/providers"; +import { useThemeOptional } from "@/providers"; function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { return ; } -function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { - return ; +function DropdownMenuPortal({ + children, + ...props +}: MenuPrimitive.Portal.Props) { + return ( + + {children} + + ); } function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { @@ -29,28 +38,28 @@ function DropdownMenuContent({ MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" >) { - const theme = useThemeOptional(); - const mode = theme?.mode ?? 'light'; - const cssVariables = theme?.cssVariables ?? defaultCssVariables; + const { resolvedMode, cssVariables, className: themeClassName } = useThemeOptional(); return ( - - - - - + +
+ + + +
+
); } diff --git a/src/components/ui/modal.tsx b/src/components/ui/modal.tsx index a957f46..7d18cf1 100644 --- a/src/components/ui/modal.tsx +++ b/src/components/ui/modal.tsx @@ -8,7 +8,7 @@ import { } from "react"; import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; -import { useThemeOptional, defaultCssVariables } from "@/providers"; +import { useThemeOptional } from "@/providers"; /* ============================================ Modal Overlay @@ -296,9 +296,7 @@ export function Modal({ className, size = "default", }: ModalProps) { - const theme = useThemeOptional(); - const mode = theme?.mode ?? 'light'; - const cssVariables = theme?.cssVariables ?? defaultCssVariables; + const { resolvedMode, cssVariables, className: themeClassName } = useThemeOptional(); const [portalContainer, setPortalContainer] = useState( null, ); @@ -314,7 +312,7 @@ export function Modal({ const container = document.createElement("div"); container.setAttribute("data-pui-modal-root", "true"); - container.className = cn("pui-root", mode, theme?.className); + container.className = cn("pui-root", resolvedMode, themeClassName); Object.assign(container.style, cssVariables); document.body.appendChild(container); @@ -331,15 +329,15 @@ export function Modal({ previousActiveElement.current.focus(); } }; - }, [open, mode, cssVariables, theme?.className]); + }, [open, resolvedMode, cssVariables, themeClassName]); // Keep portal container class and CSS variables in sync with theme useEffect(() => { if (portalContainer) { - portalContainer.className = cn("pui-root", mode, theme?.className); + portalContainer.className = cn("pui-root", resolvedMode, themeClassName); Object.assign(portalContainer.style, cssVariables); } - }, [mode, cssVariables, portalContainer, theme?.className]); + }, [resolvedMode, cssVariables, portalContainer, themeClassName]); // Handle escape key useEffect(() => { diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index 8904be5..a30cd4a 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -1,8 +1,10 @@ +"use client"; + import * as React from "react" import { Popover as PopoverPrimitive } from "@base-ui/react/popover" import { cn } from "@/lib/utils" -import { useThemeOptional, defaultCssVariables } from "@/providers"; +import { useThemeOptional } from "@/providers"; function Popover({ ...props }: PopoverPrimitive.Root.Props) { return ; @@ -12,8 +14,15 @@ function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) { return ; } -function PopoverPortal({ ...props }: PopoverPrimitive.Portal.Props) { - return ; +function PopoverPortal({ + children, + ...props +}: PopoverPrimitive.Portal.Props) { + return ( + + {children} + + ); } function PopoverContent({ @@ -28,28 +37,28 @@ function PopoverContent({ PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" >) { - const theme = useThemeOptional(); - const mode = theme?.mode ?? 'light'; - const cssVariables = theme?.cssVariables ?? defaultCssVariables; + const { resolvedMode, cssVariables, className: themeClassName } = useThemeOptional(); return ( - - - - - + +
+ + + +
+
) } diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 046e9ae..91e0231 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -1,12 +1,25 @@ +"use client"; + import { Select as SelectPrimitive } from "@base-ui/react/select"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import * as React from "react"; import { cn } from "@/lib/utils"; -import { useThemeOptional, defaultCssVariables } from "@/providers"; +import { useThemeOptional } from "@/providers"; const Select = SelectPrimitive.Root; +function SelectPortal({ + children, + ...props +}: SelectPrimitive.Portal.Props) { + return ( + + {children} + + ); +} + function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) { return ( ) { - const theme = useThemeOptional(); - const mode = theme?.mode ?? 'light'; - const cssVariables = theme?.cssVariables ?? defaultCssVariables; + const { resolvedMode, cssVariables, className: themeClassName } = useThemeOptional(); return ( - - - +
+ - - {children} - - - - + + + {children} + + + +
+ ); } diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 07ec8c3..a5cf067 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -5,7 +5,7 @@ import { Dialog as SheetPrimitive } from "@base-ui/react/dialog" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { useThemeOptional, defaultCssVariables } from "@/providers" +import { useThemeOptional } from "@/providers" import { XIcon } from "lucide-react" function Sheet({ ...props }: SheetPrimitive.Root.Props) { @@ -20,8 +20,15 @@ function SheetClose({ ...props }: SheetPrimitive.Close.Props) { return } -function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) { - return +function SheetPortal({ + children, + ...props +}: SheetPrimitive.Portal.Props) { + return ( + + {children} + + ); } function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { @@ -44,37 +51,37 @@ function SheetContent({ side?: "top" | "right" | "bottom" | "left" showCloseButton?: boolean }) { - const theme = useThemeOptional(); - const mode = theme?.mode ?? "light"; - const cssVariables = theme?.cssVariables ?? defaultCssVariables; + const { resolvedMode, cssVariables, className: themeClassName } = useThemeOptional(); return ( - - - - {children} - {showCloseButton && ( - +
+ + + {children} + {showCloseButton && ( + + } + > + - } - > - - Close - - )} - + Close + + )} + +
) } diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index 6bf4a8d..6388381 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -1,3 +1,5 @@ +"use client"; + import { CircleCheckIcon, InfoIcon, @@ -6,16 +8,16 @@ import { TriangleAlertIcon, } from "lucide-react"; import { useThemeOptional } from "@/providers"; +import { cn } from "@/lib/utils"; import { Toaster as Sonner, type ToasterProps } from "sonner"; const Toaster = ({ ...props }: ToasterProps) => { - const theme = useThemeOptional(); - const mode = theme?.mode ?? "system"; + const { resolvedMode, cssVariables, className: themeClassName } = useThemeOptional(); return ( , info: , @@ -25,6 +27,7 @@ const Toaster = ({ ...props }: ToasterProps) => { }} style={ { + ...cssVariables, "--normal-bg": "var(--popover)", "--normal-text": "var(--popover-foreground)", "--normal-border": "var(--border)", diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 0d45811..51294ad 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -1,7 +1,9 @@ +"use client"; + import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip" import { cn } from "@/lib/utils" -import { useThemeOptional, defaultCssVariables } from "@/providers"; +import { useThemeOptional } from "@/providers"; function TooltipProvider({ delay = 0, @@ -28,6 +30,17 @@ function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { return } +function TooltipPortal({ + children, + ...props +}: TooltipPrimitive.Portal.Props) { + return ( + + {children} + + ); +} + interface TooltipContentProps extends TooltipPrimitive.Popup.Props { portalClassName?: string; positionerClassName?: string; @@ -50,31 +63,31 @@ function TooltipContent({ TooltipPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" >) { - const theme = useThemeOptional(); - const mode = theme?.mode ?? 'light'; - const cssVariables = theme?.cssVariables ?? defaultCssVariables; + const { resolvedMode, cssVariables, className: themeClassName } = useThemeOptional(); return ( - - - +
+ - {children} - - - - + + {children} + + + +
+ ) } diff --git a/src/providers/theme-provider.tsx b/src/providers/theme-provider.tsx index 1ddc566..ead1853 100644 --- a/src/providers/theme-provider.tsx +++ b/src/providers/theme-provider.tsx @@ -387,25 +387,121 @@ const defaultDarkTokens: ThemeTokens = { }; /** - * ThemeProvider component for scoped theming across plugins - * - * @example - * ```tsx - * import { ThemeProvider } from '@wedevs/plugin-ui'; + * Global registry of every mounted ThemeProvider, used as a fallback theme for + * components rendered in a disconnected React root (no ThemeProvider ancestor) — + * common in WordPress where multiple bundles/roots coexist. * - * const dokanTokens = { - * primary: 'oklch(0.5410 0.2120 265.7540)', // Purple - * radius: '0.375rem', - * }; - * - * function DokanApp() { - * return ( - * - * - * - * ); - * } - * ``` + * Notes on multi-plugin / multi-instance safety: + * - Components *inside* a ThemeProvider (including portaled ones) already get the + * correct theme via React context — context flows through `createPortal`. This + * registry is only consulted when there is no provider in the React tree. + * - The registry (entries + listeners) lives on `window`, so it is shared even + * when the library is bundled multiple times by different plugins. + * - Entries are keyed per provider instance and ordered, so the "active" fallback + * is the most-recently mounted/updated provider, and it self-heals when that + * provider unmounts (falls back to the next most recent, or the default light + * theme when none remain). This avoids the stale / last-writer-wins problems of + * a single shared slot when two plugins mount providers on the same page. + */ +const GLOBAL_THEME_KEY = "__PUI_THEME__"; + +interface ThemeRegistry { + entries: Map; + listeners: Set<(context: ThemeContextValue | null) => void>; +} + +type ThemeWindow = Window & { + [GLOBAL_THEME_KEY]?: ThemeRegistry; +}; + +function isThemeRegistry(value: unknown): value is ThemeRegistry { + return ( + !!value && + (value as ThemeRegistry).entries instanceof Map && + (value as ThemeRegistry).listeners instanceof Set + ); +} + +function getRegistry(): ThemeRegistry { + // On the server there is no shared window; return an ephemeral registry. + // (Effects that register/subscribe never run during SSR, so this is only + // ever read as "empty" via getGlobalTheme's lazy initializer.) + if (typeof window === "undefined") { + return { entries: new Map(), listeners: new Set() }; + } + const w = window as ThemeWindow; + // Guard the shape: an older library version (pre-registry) stored a bare + // ThemeContextValue at this key. If another plugin on the page ships that + // version, don't crash trying to read `.entries` off it — re-initialize. + if (!isThemeRegistry(w[GLOBAL_THEME_KEY])) { + w[GLOBAL_THEME_KEY] = { entries: new Map(), listeners: new Set() }; + } + return w[GLOBAL_THEME_KEY] as ThemeRegistry; +} + +/** The most-recently registered (mounted/updated) provider's context, or null. */ +function getGlobalTheme(): ThemeContextValue | null { + const { entries } = getRegistry(); + let latest: ThemeContextValue | null = null; + for (const value of entries.values()) latest = value; + return latest; +} + +function notifyGlobalThemeListeners() { + const { listeners } = getRegistry(); + const current = getGlobalTheme(); + listeners.forEach((listener) => listener(current)); +} + +/** Register/refresh a provider instance and make it the active fallback. */ +function registerGlobalTheme(id: symbol, context: ThemeContextValue) { + const { entries } = getRegistry(); + // Re-insert so the latest update moves to the end (becomes "current"). + entries.delete(id); + entries.set(id, context); + notifyGlobalThemeListeners(); +} + +/** Remove a provider instance (on unmount); active fallback falls back. */ +function unregisterGlobalTheme(id: symbol) { + const { entries } = getRegistry(); + if (entries.delete(id)) { + notifyGlobalThemeListeners(); + } +} + +function subscribeToGlobalTheme( + listener: (context: ThemeContextValue | null) => void, +) { + const { listeners } = getRegistry(); + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Pre-computed default light CSS variables for use in portals outside ThemeProvider. + */ +export const defaultCssVariables: Record = + tokensToCssVariables(defaultLightTokens); + +/** + * Default context value used when components are rendered outside a ThemeProvider + * and no global theme has been registered yet. + */ +const DEFAULT_CONTEXT: ThemeContextValue = { + pluginId: "pui-default", + mode: "light", + setMode: () => {}, + tokens: defaultLightTokens, + resolvedMode: "light", + cssVariables: defaultCssVariables, + className: "", +}; + +/** + * ThemeProvider component for scoped theming across plugins */ export function ThemeProvider({ pluginId, @@ -503,13 +599,24 @@ export function ThemeProvider({ [pluginId, mode, setMode, mergedTokens, resolvedMode, cssVariables, className], ); + // Stable per-instance id so multiple providers (same or different plugin) + // each get their own slot in the global registry. + const [instanceId] = useState(() => Symbol(pluginId)); + + // Publish this provider to the global registry so disconnected roots can fall + // back to it; unregister on unmount so the fallback never goes stale. + useEffect(() => { + registerGlobalTheme(instanceId, contextValue); + return () => unregisterGlobalTheme(instanceId); + }, [instanceId, contextValue]); + return (
@@ -521,19 +628,6 @@ export function ThemeProvider({ /** * Hook to access theme context - * - * @example - * ```tsx - * function MyComponent() { - * const { mode, setMode, tokens, pluginId } = useTheme(); - * - * return ( - * - * ); - * } - * ``` */ export function useTheme(): ThemeContextValue { const context = useContext(ThemeContext); @@ -546,16 +640,29 @@ export function useTheme(): ThemeContextValue { } /** - * Hook to check if component is within a ThemeProvider + * Hook to check if component is within a ThemeProvider. + * If not, falls back to the most recently registered global theme, + * or the default light theme if none exists. */ -export function useThemeOptional(): ThemeContextValue | null { - return useContext(ThemeContext); -} +export function useThemeOptional(): ThemeContextValue { + const context = useContext(ThemeContext); + const [fallbackContext, setFallbackContext] = useState( + () => getGlobalTheme() ?? DEFAULT_CONTEXT, + ); -/** - * Pre-computed default light CSS variables for use in portals outside ThemeProvider. - */ -export const defaultCssVariables: Record = - tokensToCssVariables(defaultLightTokens); + useEffect(() => { + // If we have a local context, we don't need the global fallback. + if (context) return; + + // Sync immediately in case a provider registered between render and effect, + // then subscribe to subsequent global theme changes. + setFallbackContext(getGlobalTheme() ?? DEFAULT_CONTEXT); + return subscribeToGlobalTheme((newContext) => { + setFallbackContext(newContext ?? DEFAULT_CONTEXT); + }); + }, [context]); + + return context ?? fallbackContext; +} export default ThemeProvider;