mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-11 09:51:40 +00:00
* feat: add rhea * fix: blocks * feat: build chat example * fix * fix: sidebar * fix * feat: update home * fix * fix * fix * feat: optimizine fonts * feat * fix * fix * fix * fix * fix * fix * fix: font in preview * fix
324 lines
8.6 KiB
TypeScript
324 lines
8.6 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
|
|
import {
|
|
buildRegistryTheme,
|
|
DEFAULT_CONFIG,
|
|
POINTER_CURSOR_SELECTOR,
|
|
type DesignSystemConfig,
|
|
} from "@/registry/config"
|
|
import { useIframeMessageListener } from "@/app/(app)/create/hooks/use-iframe-sync"
|
|
import { FONTS } from "@/app/(app)/create/lib/fonts"
|
|
import {
|
|
useDesignSystemSearchParams,
|
|
type DesignSystemSearchParams,
|
|
} from "@/app/(app)/create/lib/search-params"
|
|
|
|
const THEME_STYLE_ELEMENT_ID = "design-system-theme-vars"
|
|
const MANAGED_BODY_CLASS_PREFIXES = ["style-", "base-color-"] as const
|
|
const POINTER_CURSOR_CSS = `@layer base {
|
|
${POINTER_CURSOR_SELECTOR} {
|
|
cursor: pointer;
|
|
}
|
|
}
|
|
`
|
|
|
|
type RegistryThemeCssVars = NonNullable<
|
|
ReturnType<typeof buildRegistryTheme>["cssVars"]
|
|
>
|
|
|
|
function removeManagedBodyClasses(body: Element) {
|
|
for (const className of Array.from(body.classList)) {
|
|
if (
|
|
MANAGED_BODY_CLASS_PREFIXES.some((prefix) => className.startsWith(prefix))
|
|
) {
|
|
body.classList.remove(className)
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildCssRule(selector: string, cssVars?: Record<string, string>) {
|
|
const declarations = Object.entries(cssVars ?? {})
|
|
.filter(([, value]) => Boolean(value))
|
|
.map(([key, value]) => ` --${key}: ${value};`)
|
|
.join("\n")
|
|
|
|
if (!declarations) {
|
|
return `${selector} {}\n`
|
|
}
|
|
|
|
return `${selector} {\n${declarations}\n}\n`
|
|
}
|
|
|
|
function buildThemeCssText(cssVars: RegistryThemeCssVars, pointer: boolean) {
|
|
return [
|
|
buildCssRule(":root", {
|
|
...(cssVars.theme ?? {}),
|
|
...(cssVars.light ?? {}),
|
|
}),
|
|
buildCssRule(".dark", cssVars.dark),
|
|
pointer ? POINTER_CURSOR_CSS : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n")
|
|
}
|
|
|
|
export function DesignSystemProvider({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode
|
|
}) {
|
|
const [searchParams, setSearchParams] = useDesignSystemSearchParams({
|
|
shallow: true, // No need to go through the server…
|
|
history: "replace", // …or push updates into the iframe history.
|
|
})
|
|
const [isReady, setIsReady] = React.useState(false)
|
|
const {
|
|
style,
|
|
theme,
|
|
font,
|
|
fontHeading,
|
|
baseColor,
|
|
chartColor,
|
|
menuAccent,
|
|
menuColor,
|
|
pointer,
|
|
radius,
|
|
} = searchParams
|
|
const effectiveRadius = style === "lyra" ? "none" : radius
|
|
const selectedFont = React.useMemo(
|
|
() => FONTS.find((fontOption) => fontOption.value === font),
|
|
[font]
|
|
)
|
|
const selectedHeadingFont = React.useMemo(() => {
|
|
if (fontHeading === "inherit" || fontHeading === font) {
|
|
return selectedFont
|
|
}
|
|
|
|
return FONTS.find((fontOption) => fontOption.value === fontHeading)
|
|
}, [font, fontHeading, selectedFont])
|
|
const initialFontSansRef = React.useRef<string | null>(null)
|
|
const initialFontHeadingRef = React.useRef<string | null>(null)
|
|
|
|
React.useEffect(() => {
|
|
initialFontSansRef.current =
|
|
document.documentElement.style.getPropertyValue("--font-sans")
|
|
initialFontHeadingRef.current =
|
|
document.documentElement.style.getPropertyValue("--font-heading")
|
|
|
|
return () => {
|
|
removeManagedBodyClasses(document.body)
|
|
document.getElementById(THEME_STYLE_ELEMENT_ID)?.remove()
|
|
|
|
if (initialFontSansRef.current) {
|
|
document.documentElement.style.setProperty(
|
|
"--font-sans",
|
|
initialFontSansRef.current
|
|
)
|
|
} else {
|
|
document.documentElement.style.removeProperty("--font-sans")
|
|
}
|
|
|
|
if (initialFontHeadingRef.current) {
|
|
document.documentElement.style.setProperty(
|
|
"--font-heading",
|
|
initialFontHeadingRef.current
|
|
)
|
|
} else {
|
|
document.documentElement.style.removeProperty("--font-heading")
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
const handleDesignSystemMessage = React.useCallback(
|
|
(nextParams: DesignSystemSearchParams) => {
|
|
setSearchParams(nextParams)
|
|
},
|
|
[setSearchParams]
|
|
)
|
|
|
|
useIframeMessageListener("design-system-params", handleDesignSystemMessage)
|
|
|
|
React.useEffect(() => {
|
|
if (style === "lyra" || (style === "sera" && radius !== "none")) {
|
|
setSearchParams({ radius: "none" })
|
|
return
|
|
}
|
|
|
|
if (style === "rhea" && radius === "large") {
|
|
setSearchParams({ radius: "default" })
|
|
}
|
|
}, [style, radius, setSearchParams])
|
|
|
|
// Use useLayoutEffect for synchronous style updates to prevent flash.
|
|
React.useLayoutEffect(() => {
|
|
if (!style || !theme || !font || !baseColor) {
|
|
return
|
|
}
|
|
|
|
const body = document.body
|
|
|
|
// Iterate over a snapshot so removals do not affect traversal.
|
|
removeManagedBodyClasses(body)
|
|
body.classList.add(`style-${style}`, `base-color-${baseColor}`)
|
|
|
|
// Update font.
|
|
// Always set --font-sans for the preview so the selected font is visible.
|
|
// The font type (sans/serif/mono) is metadata for the CLI updater.
|
|
if (selectedFont) {
|
|
document.documentElement.style.setProperty(
|
|
"--font-sans",
|
|
selectedFont.font.style.fontFamily
|
|
)
|
|
}
|
|
|
|
if (selectedHeadingFont) {
|
|
document.documentElement.style.setProperty(
|
|
"--font-heading",
|
|
selectedHeadingFont.font.style.fontFamily
|
|
)
|
|
}
|
|
|
|
setIsReady(true)
|
|
}, [
|
|
style,
|
|
theme,
|
|
font,
|
|
fontHeading,
|
|
baseColor,
|
|
selectedFont,
|
|
selectedHeadingFont,
|
|
])
|
|
|
|
const registryTheme = React.useMemo(() => {
|
|
if (!baseColor || !theme || !menuAccent || !effectiveRadius) {
|
|
return null
|
|
}
|
|
|
|
const config: DesignSystemConfig = {
|
|
...DEFAULT_CONFIG,
|
|
baseColor,
|
|
theme,
|
|
chartColor,
|
|
menuAccent,
|
|
radius: effectiveRadius,
|
|
}
|
|
|
|
return buildRegistryTheme(config)
|
|
}, [baseColor, theme, chartColor, menuAccent, effectiveRadius])
|
|
|
|
// Use useLayoutEffect for synchronous CSS var updates.
|
|
React.useLayoutEffect(() => {
|
|
if (!registryTheme || !registryTheme.cssVars) {
|
|
return
|
|
}
|
|
|
|
let styleElement = document.getElementById(
|
|
THEME_STYLE_ELEMENT_ID
|
|
) as HTMLStyleElement | null
|
|
|
|
if (!styleElement) {
|
|
styleElement = document.createElement("style")
|
|
styleElement.id = THEME_STYLE_ELEMENT_ID
|
|
document.head.appendChild(styleElement)
|
|
}
|
|
|
|
styleElement.textContent = buildThemeCssText(registryTheme.cssVars, pointer)
|
|
}, [registryTheme, pointer])
|
|
|
|
// Handle menu color inversion by adding/removing dark class to elements with cn-menu-target.
|
|
// useLayoutEffect to apply classes synchronously before paint, avoiding flash.
|
|
React.useLayoutEffect(() => {
|
|
if (!menuColor) {
|
|
return
|
|
}
|
|
|
|
const isInvertedMenu =
|
|
menuColor === "inverted" || menuColor === "inverted-translucent"
|
|
const isTranslucentMenu =
|
|
menuColor === "default-translucent" ||
|
|
menuColor === "inverted-translucent"
|
|
let frameId = 0
|
|
|
|
const updateMenuElements = () => {
|
|
const allElements = document.querySelectorAll<HTMLElement>(
|
|
".cn-menu-target, [data-menu-translucent]"
|
|
)
|
|
|
|
if (allElements.length === 0) {
|
|
return
|
|
}
|
|
|
|
// Disable transitions while toggling classes.
|
|
allElements.forEach((element) => {
|
|
element.style.transition = "none"
|
|
})
|
|
|
|
allElements.forEach((element) => {
|
|
if (element.classList.contains("cn-menu-target")) {
|
|
if (isInvertedMenu) {
|
|
element.classList.add("dark")
|
|
} else {
|
|
element.classList.remove("dark")
|
|
}
|
|
}
|
|
|
|
// When translucent is enabled, move from data-attr to class so styles apply.
|
|
// When disabled, move back to a data-attr so the element stays queryable
|
|
// for future toggles without losing its identity as a menu element.
|
|
if (isTranslucentMenu) {
|
|
element.classList.add("cn-menu-translucent")
|
|
element.removeAttribute("data-menu-translucent")
|
|
} else if (element.classList.contains("cn-menu-translucent")) {
|
|
element.classList.remove("cn-menu-translucent")
|
|
element.setAttribute("data-menu-translucent", "")
|
|
}
|
|
})
|
|
|
|
// Force a reflow, then re-enable transitions.
|
|
void document.body.offsetHeight
|
|
allElements.forEach((element) => {
|
|
element.style.transition = ""
|
|
})
|
|
}
|
|
|
|
const scheduleMenuUpdate = () => {
|
|
if (frameId) {
|
|
return
|
|
}
|
|
|
|
frameId = window.requestAnimationFrame(() => {
|
|
frameId = 0
|
|
updateMenuElements()
|
|
})
|
|
}
|
|
|
|
// Update existing menu elements.
|
|
updateMenuElements()
|
|
|
|
// Watch for new menu elements being added to the DOM.
|
|
const observer = new MutationObserver(() => {
|
|
scheduleMenuUpdate()
|
|
})
|
|
|
|
observer.observe(document.body, {
|
|
childList: true,
|
|
subtree: true,
|
|
})
|
|
|
|
return () => {
|
|
observer.disconnect()
|
|
if (frameId) {
|
|
window.cancelAnimationFrame(frameId)
|
|
}
|
|
}
|
|
}, [menuColor])
|
|
|
|
if (!isReady) {
|
|
return null
|
|
}
|
|
|
|
return <>{children}</>
|
|
}
|