Skip to content

Commit

Permalink
fix: stuck light/dark theme (#1135)
Browse files Browse the repository at this point in the history
  • Loading branch information
abvthecity authored Jul 11, 2024
1 parent 028bbf2 commit 4004f14
Show file tree
Hide file tree
Showing 34 changed files with 338 additions and 173 deletions.
1 change: 0 additions & 1 deletion packages/commons/fdr-utils/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { DocsV1Read, FernNavigation } from "@fern-api/fdr-sdk";
// import { FlattenedApiDefinition } from "./flattenApiDefinition";

export interface ColorsConfig {
light: DocsV1Read.ThemeConfig | undefined;
Expand Down
13 changes: 4 additions & 9 deletions packages/ui/app/.storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
import { FernTooltipProvider, Toaster } from "@fern-ui/components";
import { withThemeByClassName } from "@storybook/addon-themes";
import type { Preview } from "@storybook/react";
import { ThemeProvider } from "next-themes";
import React from "react";
import "../src/next-app/globals.scss";
import "./variables.css";

const globalDecorator = (Story) => (
<React.Fragment>
<ThemeProvider>
<FernTooltipProvider>
<Story />
<Toaster />
</FernTooltipProvider>
</ThemeProvider>
</React.Fragment>
<FernTooltipProvider>
<Story />
<Toaster />
</FernTooltipProvider>
);
export const decorators = [
globalDecorator,
Expand Down
1 change: 0 additions & 1 deletion packages/ui/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@
"moment": "^2.30.1",
"next": "^14.2.4",
"next-mdx-remote": "^5.0.0",
"next-themes": "^0.3.0",
"nprogress": "^0.2.0",
"numeral": "^2.0.6",
"parse-numeric-range": "^1.3.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/app/src/api-page/endpoints/EndpointContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import dynamic from "next/dynamic";
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { useInView } from "react-intersection-observer";
import { useCallbackOne } from "use-memo-one";
import { useAtomEffect } from "../../atoms";
import { FERN_LANGUAGE_ATOM } from "../../atoms/lang";
import { CONTENT_HEIGHT_ATOM } from "../../atoms/layout";
import { HASH_ATOM } from "../../atoms/location";
Expand All @@ -14,7 +15,6 @@ import { store } from "../../atoms/store";
import { FERN_STREAM_ATOM } from "../../atoms/stream";
import { BREAKPOINT_ATOM, MOBILE_SIDEBAR_ENABLED_ATOM } from "../../atoms/viewport";
import { Breadcrumbs } from "../../components/Breadcrumbs";
import { useAtomEffect } from "../../hooks/useAtomEffect";
import { ResolvedEndpointDefinition, ResolvedError, ResolvedTypeDefinition } from "../../resolver/types";
import { ApiPageDescription } from "../ApiPageDescription";
import { JsonPropertyPath } from "../examples/JsonPropertyPath";
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/app/src/api-playground/PlaygroundContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import useSWR from "swr";
import urljoin from "url-join";
import { useCallbackOne as useStableCallback } from "use-memo-one";
import { capturePosthogEvent } from "../analytics/posthog";
import { useAtomEffect } from "../atoms";
import { APIS, FLATTENED_APIS_ATOM } from "../atoms/apis";
import { useBasePath } from "../atoms/navigation";
import {
Expand All @@ -16,7 +17,6 @@ import {
useInitPlaygroundRouter,
useOpenPlayground,
} from "../atoms/playground";
import { useAtomEffect } from "../hooks/useAtomEffect";
import { ResolvedApiDefinition, ResolvedRootPackage, isEndpoint, isWebSocket } from "../resolver/types";
import { getInitialEndpointRequestFormStateWithExample } from "./PlaygroundDrawer";

Expand Down
1 change: 1 addition & 0 deletions packages/ui/app/src/atoms/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./useAtomEffect";
File renamed without changes.
2 changes: 2 additions & 0 deletions packages/ui/app/src/atoms/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./hooks";
export * from "./utils";
2 changes: 1 addition & 1 deletion packages/ui/app/src/atoms/playground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { capturePosthogEvent } from "../analytics/posthog";
import { PlaygroundRequestFormState } from "../api-playground/types";
import { useAtomEffect } from "../hooks/useAtomEffect";
import { APIS } from "./apis";
import { FEATURE_FLAGS_ATOM } from "./flags";
import { useAtomEffect } from "./hooks";
import { BELOW_HEADER_HEIGHT_ATOM } from "./layout";
import { LOCATION_ATOM } from "./location";
import { NAVIGATION_NODES_ATOM } from "./navigation";
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/app/src/atoms/sidebar.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { atom, useAtomValue, useSetAtom } from "jotai";
import { useTheme } from "next-themes";
import { useCallback, useEffect } from "react";
import { DOCS_LAYOUT_ATOM } from "./layout";
import { CURRENT_NODE_ATOM, RESOLVED_PATH_ATOM, SIDEBAR_ROOT_NODE_ATOM } from "./navigation";
import { useSetTheme, useTheme } from "./theme";
import { IS_MOBILE_SCREEN_ATOM, MOBILE_SIDEBAR_ENABLED_ATOM } from "./viewport";

export const SEARCH_DIALOG_OPEN_ATOM = atom(false);
Expand Down Expand Up @@ -77,7 +77,8 @@ export const SIDEBAR_DISMISSABLE_ATOM = atom((get) => {
export function useMessageHandler(): void {
const openSearchDialog = useOpenSearchDialog();
const openMobileSidebar = useOpenMobileSidebar();
const { resolvedTheme, setTheme } = useTheme();
const resolvedTheme = useTheme();
const setTheme = useSetTheme();
useEffect(() => {
if (typeof window === "undefined") {
return;
Expand Down
194 changes: 194 additions & 0 deletions packages/ui/app/src/atoms/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { noop } from "@fern-ui/core-utils";
import { ColorsConfig } from "@fern-ui/fdr-utils";
import { atom, useAtom, useAtomValue } from "jotai";
import { atomWithRefresh } from "jotai/utils";
import { createElement, memo } from "react";
import { useCallbackOne } from "use-memo-one";
import { z } from "zod";
import { getThemeColor } from "../next-app/utils/getColorVariables";
import { useAtomEffect } from "./hooks/useAtomEffect";
import { atomWithStorageString } from "./utils/atomWithStorageString";

const STORAGE_KEY = "theme";
const SYSTEM = "system" as const;
const SYSTEM_THEMES = ["light" as const, "dark" as const];
const MEDIA = "(prefers-color-scheme: dark)";

type Theme = (typeof SYSTEM_THEMES)[number];

const SETTABLE_THEME_ATOM = atomWithStorageString<Theme | typeof SYSTEM>(STORAGE_KEY, SYSTEM, {
validate: z.union([z.literal("system"), z.literal("light"), z.literal("dark")]),
getOnInit: true,
});

const IS_SYSTEM_THEME_ATOM = atom((get) => get(SETTABLE_THEME_ATOM) === SYSTEM);

export const COLORS_ATOM = atom<ColorsConfig>({ dark: undefined, light: undefined });
export const AVAILABLE_THEMES_ATOM = atom((get) => getAvailableThemes(get(COLORS_ATOM)));

export function useColors(): ColorsConfig {
return useAtomValue(COLORS_ATOM);
}

export const THEME_SWITCH_ENABLED_ATOM = atom((get) => {
const availableThemes = get(AVAILABLE_THEMES_ATOM);
return availableThemes.length > 1;
});

export const THEME_ATOM = atomWithRefresh((get): Theme => {
const storedTheme = get(SETTABLE_THEME_ATOM);
const availableThemes = get(AVAILABLE_THEMES_ATOM);
if (storedTheme === SYSTEM) {
if (typeof window !== "undefined") {
return getSystemTheme();
}
} else if (availableThemes.includes(storedTheme)) {
return storedTheme;
}
return availableThemes[0];
});

export function useTheme(): Theme {
return useAtomValue(THEME_ATOM);
}

export function useSetTheme(): (theme: Theme | typeof SYSTEM) => void {
return useAtom(SETTABLE_THEME_ATOM)[1];
}

export function useToggleTheme(): () => void {
const setTheme = useSetTheme();
const theme = useTheme();
return () => setTheme(theme === "dark" ? "light" : "dark");
}

export const THEME_BG_COLOR = atom((get) => {
const theme = get(THEME_ATOM);
const colors = get(COLORS_ATOM);
const config = colors[theme];
if (config == null) {
return undefined;
}
return getThemeColor(config);
});

const disableAnimation = () => {
if (typeof document === "undefined") {
return noop;
}

const css = document.createElement("style");
css.appendChild(
document.createTextNode(
"*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}",
),
);
document.head.appendChild(css);

return () => {
// Force restyle
(() => window.getComputedStyle(document.body))();

// Wait for next tick before removing
setTimeout(() => {
document.head.removeChild(css);
}, 1);
};
};

export type AvailableThemes = [Theme] | [Theme, Theme];
const getAvailableThemes = (colors: Partial<ColorsConfig> = {}): AvailableThemes => {
if ((colors.dark != null && colors.light != null) || (colors.dark == null && colors.light == null)) {
return ["light", "dark"];
}
return colors.dark != null ? ["dark"] : ["light"];
};

const getSystemTheme = (e?: MediaQueryList | MediaQueryListEvent) => {
if (!e) {
e = window.matchMedia(MEDIA);
}
const isDark = e.matches;
const systemTheme = isDark ? "dark" : "light";
return systemTheme;
};

export function useInitializeTheme(): void {
useAtomEffect(
useCallbackOne((get) => {
const enableAnimation = disableAnimation();
const theme = get(THEME_ATOM);
const d = document.documentElement;
d.classList.remove(...SYSTEM_THEMES);
d.classList.add(theme);
d.style.colorScheme = theme;
enableAnimation();
}, []),
);

useAtomEffect(
useCallbackOne((get, set) => {
const handleMediaQuery = () => {
if (get(IS_SYSTEM_THEME_ATOM)) {
set(THEME_ATOM);
}
};

const media = window.matchMedia(MEDIA);

// Intentionally use deprecated listener methods to support iOS & old browsers
// eslint-disable-next-line deprecation/deprecation
media.addListener(handleMediaQuery);
handleMediaQuery();

// eslint-disable-next-line deprecation/deprecation
return () => media.removeListener(handleMediaQuery);
}, []),
);
}

// this script cannot reference any other code since it will be stringified to be executed in the browser
export const script = (themes: AvailableThemes): void => {
const el = document.documentElement;

function updateDOM(theme: string) {
el.classList.remove("light", "dark");
el.classList.add(theme);
el.style.colorScheme = theme;
}

function getSystemTheme() {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}

if (themes.length === 1) {
updateDOM(themes[0]);
} else {
try {
const themeName = localStorage.getItem("theme") ?? themes[0];
const isSystem = themes.length > 0 && themeName === "system";
const theme = isSystem ? getSystemTheme() : themeName;
updateDOM(theme);
} catch {
//
}
}
};

// including the ThemeScript component will prevent a flash of unstyled content (FOUC) when the page loads
// by setting the theme from local storage before the page is rendered
export const ThemeScript = memo(
({ nonce, colors }: { nonce?: string; colors?: ColorsConfig }) => {
const scriptArgs = JSON.stringify(getAvailableThemes(colors));

return createElement("script", {
suppressHydrationWarning: true,
nonce: typeof window === "undefined" ? nonce : "",
dangerouslySetInnerHTML: { __html: `(${script.toString()})(${scriptArgs})` },
});
},
(prev, next) =>
prev.nonce === next.nonce && getAvailableThemes(prev.colors).length === getAvailableThemes(next.colors).length,
);

ThemeScript.displayName = "ThemeScript";
78 changes: 78 additions & 0 deletions packages/ui/app/src/atoms/utils/atomWithStorageString.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { noop } from "@fern-ui/core-utils";
import { atomWithStorage } from "jotai/utils";
import { z } from "zod";

export function atomWithStorageString<VALUE extends string>(
key: string,
value: VALUE,
{ validate, getOnInit }: { validate?: z.ZodType<VALUE>; getOnInit?: boolean } = {},
): ReturnType<typeof atomWithStorage<VALUE>> {
return atomWithStorage<VALUE>(
key,
value,
{
getItem: (key, initialValue) => {
if (typeof window === "undefined") {
return initialValue;
}

try {
const stored: string | null = window.localStorage.getItem(key);
if (stored == null) {
return initialValue;
}
if (validate) {
const parsed = validate.safeParse(stored);
if (parsed.success) {
return parsed.data;
}
}
} catch {
// ignore
}
return initialValue;
},
setItem: (key, newValue) => {
if (typeof window === "undefined") {
return;
}

try {
window.localStorage.setItem(key, newValue);
} catch {
// ignore
}
},
removeItem: (key) => {
if (typeof window === "undefined") {
return;
}

try {
window.localStorage.removeItem(key);
} catch {
// ignore
}
},
subscribe: (key, callback, initialValue) => {
if (typeof window === "undefined") {
return noop;
}

const listener = (e: StorageEvent) => {
if (e.key === key && e.newValue !== e.oldValue) {
callback(
(validate != null ? validate.safeParse(e.newValue)?.data : (e.newValue as VALUE)) ??
initialValue,
);
}
};
window.addEventListener("storage", listener);
return () => {
window.removeEventListener("storage", listener);
};
},
},
{ getOnInit },
);
}
1 change: 1 addition & 0 deletions packages/ui/app/src/atoms/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./atomWithStorageString";
Loading

0 comments on commit 4004f14

Please sign in to comment.