"use client"; import { useTemplateCopy } from "@/components/foundations/template-copy/template-copy"; import { useEffect, useRef, useSyncExternalStore } from "react"; import { flushSync } from "react-dom"; import { RiMoonLine, RiSunLine } from "@remixicon/react"; import { Switch as AriaSwitch } from "react-aria-components"; import { SwitchTrack } from "@/components/base/switch/switch"; import { cx } from "@/utils/cx"; export type ThemeMode = "light" | "dark"; export const THEME_STORAGE_KEY = "boardui:theme"; export const THEME_CHANGE_EVENT = "boardui:theme-change"; const THEME_TRANSITION_DURATION = 820; const THEME_TRANSITION_EASING = "cubic-bezier(0.16, 1, 0.3, 1)"; const THEME_TRANSITION_STYLE_ID = "boardui-theme-transition-style"; type ThemeTransitionOrigin = { x: number; y: number }; type ViewTransition = { ready: Promise; finished: Promise; }; type ViewTransitionDocument = Document & { startViewTransition?: (update: () => void) => ViewTransition; }; let themeTransitionRunning = false; function createBlurCircleMask() { const svg = [ '', '', '', '', "", ].join(""); return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; } function installThemeTransitionStyle( { x, y }: ThemeTransitionOrigin, radius: number, duration: number, ) { document.getElementById(THEME_TRANSITION_STYLE_ID)?.remove(); const mask = createBlurCircleMask(); const finalMaskSize = radius * 2.5; const finalMaskX = x - finalMaskSize / 2; const finalMaskY = y - finalMaskSize / 2; const style = document.createElement("style"); style.id = THEME_TRANSITION_STYLE_ID; style.textContent = ` ::view-transition-new(root) { -webkit-mask-image: ${mask}; mask-image: ${mask}; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; animation: boardui-theme-mask-reveal ${duration}ms ${THEME_TRANSITION_EASING} both; transform-origin: ${x}px ${y}px; will-change: -webkit-mask-size, -webkit-mask-position, mask-size, mask-position; } @keyframes boardui-theme-mask-reveal { from { -webkit-mask-position: ${x}px ${y}px; mask-position: ${x}px ${y}px; -webkit-mask-size: 0px 0px; mask-size: 0px 0px; } to { -webkit-mask-position: ${finalMaskX}px ${finalMaskY}px; mask-position: ${finalMaskX}px ${finalMaskY}px; -webkit-mask-size: ${finalMaskSize}px ${finalMaskSize}px; mask-size: ${finalMaskSize}px ${finalMaskSize}px; } } `; document.head.appendChild(style); return style; } function storedTheme(): ThemeMode { if (typeof window === "undefined") return "light"; try { return window.localStorage.getItem(THEME_STORAGE_KEY) === "dark" ? "dark" : "light"; } catch { return "light"; } } export function applyTheme(theme: ThemeMode, { persist = true }: { persist?: boolean } = {}) { if (typeof document === "undefined") return; document.documentElement.classList.toggle("dark", theme === "dark"); if (persist) { try { window.localStorage.setItem(THEME_STORAGE_KEY, theme); } catch { // The control remains usable when storage is blocked or unavailable. } } window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail: theme })); } function elementCenter(element: HTMLElement | null): ThemeTransitionOrigin { if (!element) { return { x: window.innerWidth / 2, y: window.innerHeight / 2 }; } const rect = element.getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; } function normalizedOrigin( origin: ThemeTransitionOrigin | null, element: HTMLElement | null, ): ThemeTransitionOrigin { const fallback = elementCenter(element); const x = origin?.x ?? fallback.x; const y = origin?.y ?? fallback.y; return { x: Math.min(Math.max(x, 0), window.innerWidth), y: Math.min(Math.max(y, 0), window.innerHeight), }; } /** * Reveals the next theme from the interaction point using the View Transition * API. Keyboard activation starts at the control's center. Unsupported * browsers and reduced-motion users receive the same immediate theme change. */ export async function applyThemeWithTransition( theme: ThemeMode, { origin = null, element = null, duration = THEME_TRANSITION_DURATION, }: { origin?: ThemeTransitionOrigin | null; element?: HTMLElement | null; duration?: number; } = {}, ) { if (typeof document === "undefined" || currentTheme() === theme) return; const transitionDocument = document as ViewTransitionDocument; const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (!transitionDocument.startViewTransition || reduceMotion) { applyTheme(theme); return; } if (themeTransitionRunning) return; const { x, y } = normalizedOrigin(origin, element); const radius = Math.hypot( Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y), ); themeTransitionRunning = true; document.documentElement.classList.add("theme-transitioning"); const transitionStyle = installThemeTransitionStyle({ x, y }, radius, duration); try { const transition = transitionDocument.startViewTransition(() => { flushSync(() => applyTheme(theme)); }); await transition.ready; await transition.finished; } catch { // If the browser aborts a transition mid-flight, the theme still changes. if (currentTheme() !== theme) applyTheme(theme); } finally { transitionStyle.remove(); document.documentElement.classList.remove("theme-transitioning"); themeTransitionRunning = false; } } function currentTheme(): ThemeMode { if (typeof document === "undefined") return "light"; return document.documentElement.classList.contains("dark") ? "dark" : "light"; } function subscribe(onStoreChange: () => void) { const onThemeChange = () => onStoreChange(); const onStorage = (event: StorageEvent) => { if (event.key !== THEME_STORAGE_KEY) return; applyTheme(event.newValue === "dark" ? "dark" : "light", { persist: false }); }; window.addEventListener(THEME_CHANGE_EVENT, onThemeChange); window.addEventListener("storage", onStorage); return () => { window.removeEventListener(THEME_CHANGE_EVENT, onThemeChange); window.removeEventListener("storage", onStorage); }; } export function useThemeMode(): ThemeMode { return useSyncExternalStore(subscribe, currentTheme, () => "light"); } export interface ThemeToggleProps { /** Compact icon-only treatment for a collapsed sidebar rail. */ collapsed?: boolean; /** * Visual treatment for the control. `glass-segmented` is the landing nav's * skin: literal black/white rather than theme tokens, because it sits on the * hero shader's near-white band in both themes and a token that follows the * page would invert out of sight in dark mode. */ appearance?: "sidebar" | "segmented" | "sidebar-segmented" | "glass-segmented"; className?: string; /** Size of segmented controls; small fits inside dropdown rows. */ size?: "small" | "medium"; /** Circular reveal duration in milliseconds. */ transitionDuration?: number; } /** Manual light/dark control. It never reads the operating-system theme. */ export function ThemeToggle({ collapsed = false, appearance = "sidebar", className, size = "medium", transitionDuration = THEME_TRANSITION_DURATION, }: ThemeToggleProps) { const localize = useTemplateCopy(); const theme = useThemeMode(); const dark = theme === "dark"; const switchRef = useRef(null); const pointerOriginRef = useRef(null); // Makes the component self-contained in customer projects. BoardUI itself // also runs the same lookup before hydration to avoid a first-paint flash. useEffect(() => { applyTheme(storedTheme(), { persist: false }); }, []); if ( appearance === "segmented" || appearance === "sidebar-segmented" || appearance === "glass-segmented" ) { const glass = appearance === "glass-segmented"; const small = size === "small" && !glass; const sidebarSurface = appearance === "sidebar-segmented"; const options = [ { mode: "light" as const, label: "Use light mode", Icon: RiSunLine }, { mode: "dark" as const, label: "Use dark mode", Icon: RiMoonLine }, ]; return localize((
{options.map(({ mode, label, Icon }) => { const selected = theme === mode; return ( ); })}
)); } if (collapsed) { const Icon = dark ? RiSunLine : RiMoonLine; return localize(( )); } return localize(( { pointerOriginRef.current = { x: event.clientX, y: event.clientY }; }} onChange={(selected) => { const origin = pointerOriginRef.current; pointerOriginRef.current = null; void applyThemeWithTransition(selected ? "dark" : "light", { origin, element: switchRef.current, duration: transitionDuration, }); }} aria-label="Dark mode" className={({ isFocusVisible }) => cx( "flex w-full cursor-pointer items-center justify-between rounded-2lg p-2", "transition-colors duration-150 ease hover:bg-background-secondary-hover", isFocusVisible && "ring-2 ring-inset ring-border-focus-ring", className, ) } > {(state) => ( <> Dark mode )} )); }