feat: support query theme bootstrap for embeds
This commit is contained in:
@@ -26,10 +26,22 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="zh" className={`${outfit.variable} ${jetbrains.variable} h-full antialiased dark`} suppressHydrationWarning>
|
<html lang="zh" className={`${outfit.variable} ${jetbrains.variable} h-full antialiased`} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<script dangerouslySetInnerHTML={{ __html: `
|
<script dangerouslySetInnerHTML={{ __html: `
|
||||||
(function(){
|
(function(){
|
||||||
|
var q=null;
|
||||||
|
try{
|
||||||
|
var rawTheme=new URLSearchParams(window.location.search).get('theme');
|
||||||
|
if(rawTheme){
|
||||||
|
rawTheme=rawTheme.toLowerCase();
|
||||||
|
if(rawTheme==='light'||rawTheme==='dark'){
|
||||||
|
q=rawTheme;
|
||||||
|
}else if(rawTheme==='auto'||rawTheme==='system'){
|
||||||
|
q=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}catch(e){}
|
||||||
var parentTheme=null;
|
var parentTheme=null;
|
||||||
try{
|
try{
|
||||||
if(window.self!==window.top){
|
if(window.self!==window.top){
|
||||||
@@ -38,7 +50,8 @@ export default function RootLayout({
|
|||||||
}
|
}
|
||||||
}catch(e){}
|
}catch(e){}
|
||||||
var t=localStorage.getItem('theme')||'system';
|
var t=localStorage.getItem('theme')||'system';
|
||||||
var r=parentTheme||(t==='system'?window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light':t);
|
var r=q||parentTheme||(t==='system'?window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light':t);
|
||||||
|
document.documentElement.classList.remove('light','dark');
|
||||||
document.documentElement.classList.add(r);
|
document.documentElement.classList.add(r);
|
||||||
document.documentElement.setAttribute('data-theme',r);
|
document.documentElement.setAttribute('data-theme',r);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { resolveIncomingThemeMode, resolveStoredTheme } from "./theme-sync";
|
import {
|
||||||
|
parseThemeQuery,
|
||||||
|
resolveIncomingThemeMode,
|
||||||
|
resolveStoredTheme,
|
||||||
|
} from "./theme-sync";
|
||||||
|
|
||||||
describe("theme sync helpers", () => {
|
describe("theme sync helpers", () => {
|
||||||
test("resolves stored analytics theme values", () => {
|
test("resolves stored analytics theme values", () => {
|
||||||
@@ -17,4 +21,14 @@ describe("theme sync helpers", () => {
|
|||||||
expect(resolveIncomingThemeMode("system", true)).toBe("dark");
|
expect(resolveIncomingThemeMode("system", true)).toBe("dark");
|
||||||
expect(resolveIncomingThemeMode("unknown", true)).toBeNull();
|
expect(resolveIncomingThemeMode("unknown", true)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("parses theme override from query params", () => {
|
||||||
|
expect(parseThemeQuery("light")).toBe("light");
|
||||||
|
expect(parseThemeQuery("dark")).toBe("dark");
|
||||||
|
expect(parseThemeQuery("auto")).toBe("system");
|
||||||
|
expect(parseThemeQuery("system")).toBe("system");
|
||||||
|
expect(parseThemeQuery("LIGHT")).toBe("light");
|
||||||
|
expect(parseThemeQuery("unknown")).toBeNull();
|
||||||
|
expect(parseThemeQuery(null)).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
import type { Theme } from "./theme";
|
import type { Theme } from "./theme";
|
||||||
|
|
||||||
export type ResolvedTheme = "light" | "dark";
|
export type ResolvedTheme = "light" | "dark";
|
||||||
|
export type ThemeQueryMode = "light" | "dark" | "system";
|
||||||
|
|
||||||
|
export function parseThemeQuery(value: string | null | undefined): ThemeQueryMode | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.toLowerCase();
|
||||||
|
|
||||||
|
if (normalized === "light" || normalized === "dark") {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === "auto" || normalized === "system") {
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveStoredTheme(
|
export function resolveStoredTheme(
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useState, useEffect, startTransition, type ReactNode } from "react";
|
import { createContext, useContext, useState, useEffect, startTransition, type ReactNode } from "react";
|
||||||
import { resolveIncomingThemeMode, resolveStoredTheme, type ResolvedTheme } from "./theme-sync";
|
import {
|
||||||
|
parseThemeQuery,
|
||||||
|
resolveIncomingThemeMode,
|
||||||
|
resolveStoredTheme,
|
||||||
|
type ResolvedTheme,
|
||||||
|
} from "./theme-sync";
|
||||||
|
|
||||||
export type Theme = "light" | "dark" | "system";
|
export type Theme = "light" | "dark" | "system";
|
||||||
|
|
||||||
@@ -51,6 +56,14 @@ function getParentResolvedTheme(): ResolvedTheme | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getThemeQuery(): Theme | null {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseThemeQuery(new URLSearchParams(window.location.search).get("theme"));
|
||||||
|
}
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextType>({
|
const ThemeContext = createContext<ThemeContextType>({
|
||||||
theme: "system",
|
theme: "system",
|
||||||
setTheme: () => {},
|
setTheme: () => {},
|
||||||
@@ -61,9 +74,18 @@ const ThemeContext = createContext<ThemeContextType>({
|
|||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [theme, setThemeState] = useState<Theme>("system");
|
const [theme, setThemeState] = useState<Theme>("system");
|
||||||
const [isEmbedded, setIsEmbedded] = useState(getIsEmbedded);
|
const [isEmbedded, setIsEmbedded] = useState(getIsEmbedded);
|
||||||
|
const [queryTheme, setQueryTheme] = useState<Theme | null>(getThemeQuery);
|
||||||
const [parentResolved, setParentResolved] = useState<ResolvedTheme | null>(getParentResolvedTheme);
|
const [parentResolved, setParentResolved] = useState<ResolvedTheme | null>(getParentResolvedTheme);
|
||||||
const [resolved, setResolved] = useState<ResolvedTheme>(
|
const [resolved, setResolved] = useState<ResolvedTheme>(
|
||||||
() => getParentResolvedTheme() ?? resolveStoredTheme("system", getPrefersDark()),
|
() => {
|
||||||
|
const nextQueryTheme = getThemeQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
getParentResolvedTheme()
|
||||||
|
?? (nextQueryTheme ? resolveStoredTheme(nextQueryTheme, getPrefersDark()) : null)
|
||||||
|
?? resolveStoredTheme("system", getPrefersDark())
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -77,10 +99,12 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextIsEmbedded = getIsEmbedded();
|
const nextIsEmbedded = getIsEmbedded();
|
||||||
|
const nextQueryTheme = getThemeQuery();
|
||||||
const nextParentResolved = getParentResolvedTheme();
|
const nextParentResolved = getParentResolvedTheme();
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setIsEmbedded(nextIsEmbedded);
|
setIsEmbedded(nextIsEmbedded);
|
||||||
|
setQueryTheme(nextQueryTheme);
|
||||||
setParentResolved((current) => current ?? nextParentResolved);
|
setParentResolved((current) => current ?? nextParentResolved);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -111,6 +135,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
|
const activeTheme = queryTheme ?? theme;
|
||||||
|
const queryResolved = queryTheme
|
||||||
|
? resolveStoredTheme(queryTheme, mq.matches)
|
||||||
|
: null;
|
||||||
|
|
||||||
const applyResolved = (nextResolved: ResolvedTheme) => {
|
const applyResolved = (nextResolved: ResolvedTheme) => {
|
||||||
setResolved(nextResolved);
|
setResolved(nextResolved);
|
||||||
@@ -119,19 +147,23 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
root.setAttribute("data-theme", nextResolved);
|
root.setAttribute("data-theme", nextResolved);
|
||||||
};
|
};
|
||||||
|
|
||||||
applyResolved(parentResolved ?? resolveStoredTheme(theme, mq.matches));
|
applyResolved(
|
||||||
|
parentResolved
|
||||||
|
?? queryResolved
|
||||||
|
?? resolveStoredTheme(theme, mq.matches),
|
||||||
|
);
|
||||||
|
|
||||||
if (parentResolved !== null || theme !== "system") {
|
if (parentResolved !== null || activeTheme !== "system") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSystemThemeChange = (event: MediaQueryListEvent) => {
|
const handleSystemThemeChange = (event: MediaQueryListEvent) => {
|
||||||
applyResolved(resolveStoredTheme(theme, event.matches));
|
applyResolved(resolveStoredTheme(activeTheme, event.matches));
|
||||||
};
|
};
|
||||||
|
|
||||||
mq.addEventListener("change", handleSystemThemeChange);
|
mq.addEventListener("change", handleSystemThemeChange);
|
||||||
return () => mq.removeEventListener("change", handleSystemThemeChange);
|
return () => mq.removeEventListener("change", handleSystemThemeChange);
|
||||||
}, [parentResolved, theme]);
|
}, [parentResolved, queryTheme, theme]);
|
||||||
|
|
||||||
const setTheme = (t: Theme) => {
|
const setTheme = (t: Theme) => {
|
||||||
setThemeState(t);
|
setThemeState(t);
|
||||||
|
|||||||
Reference in New Issue
Block a user