chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
export * from './lib/theme-panel';
|
||||
export * from './lib/theme-resolver';
|
||||
export * from './lib/theme-context';
|
||||
export * from './lib/rankdir-context';
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { RenderRankDir } from '@nx/graph';
|
||||
|
||||
export const localStorageRankDirKey = 'nx-dep-graph-rankdir';
|
||||
|
||||
interface RankDirContextValue {
|
||||
rankDir: RenderRankDir;
|
||||
setRankDir: (rankDir: RenderRankDir) => void;
|
||||
}
|
||||
|
||||
const RankDirContext = createContext<RankDirContextValue | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
export function RankDirProvider({ children }: { children: ReactNode }) {
|
||||
const [rankDir, setRankDir] = useState<RenderRankDir>(
|
||||
(localStorage.getItem(localStorageRankDirKey) as RenderRankDir) || 'TB'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(localStorageRankDirKey, rankDir);
|
||||
}, [rankDir]);
|
||||
|
||||
return (
|
||||
<RankDirContext.Provider value={{ rankDir, setRankDir }}>
|
||||
{children}
|
||||
</RankDirContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRankDir() {
|
||||
const context = useContext(RankDirContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useRankDir must be used within a RankDirProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import {
|
||||
Theme,
|
||||
themeResolver,
|
||||
localStorageThemeKey,
|
||||
getSystemTheme,
|
||||
} from './theme-resolver';
|
||||
import { RenderTheme } from '@nx/graph';
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
resolvedTheme: RenderTheme;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
(localStorage.getItem(localStorageThemeKey) as Theme) || 'system'
|
||||
);
|
||||
const [resolvedTheme, setResolvedTheme] = useState<RenderTheme>(() =>
|
||||
theme === 'system' ? getSystemTheme() : theme
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
themeResolver(theme, setResolvedTheme);
|
||||
}, [theme, setResolvedTheme]);
|
||||
|
||||
const handleSetTheme = (newTheme: Theme) => {
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{ theme, setTheme: handleSetTheme, resolvedTheme }}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { ThemePanel } from './theme-panel';
|
||||
import { ThemeProvider } from './theme-context';
|
||||
|
||||
const meta: Meta<typeof ThemePanel> = {
|
||||
component: ThemePanel,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<ThemeProvider>
|
||||
<div className="block h-auto w-auto border-2 bg-white px-8 py-4 dark:bg-slate-800">
|
||||
<div className="flex justify-end">
|
||||
<Story className="justify-items-end" />
|
||||
</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
),
|
||||
],
|
||||
title: 'ThemePanel',
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ThemePanel>;
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {},
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuItems,
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import {
|
||||
ComputerDesktopIcon,
|
||||
MoonIcon,
|
||||
SunIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import classNames from 'classnames';
|
||||
import { Fragment } from 'react';
|
||||
import { useTheme } from './theme-context';
|
||||
|
||||
export function ThemePanel(): JSX.Element {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<MenuButton
|
||||
className="inline-flex w-full justify-center rounded-md p-2 text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 dark:text-sky-500"
|
||||
data-cy="theme-open-modal-button"
|
||||
>
|
||||
<span className="sr-only">Theme switcher</span>
|
||||
{theme === 'system' && (
|
||||
<ComputerDesktopIcon className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
{theme === 'light' && (
|
||||
<SunIcon className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
{theme === 'dark' && (
|
||||
<MoonIcon className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</MenuButton>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems className="absolute right-0 z-50 mt-2 w-36 origin-top-right rounded-md bg-white text-slate-500 shadow-lg ring-1 ring-slate-900/10 ring-opacity-5 focus:outline-none dark:bg-slate-800 dark:text-slate-400 dark:ring-0">
|
||||
<div className="px-1 py-1">
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<button
|
||||
data-cy="system-theme-button"
|
||||
className={classNames(
|
||||
theme === 'system' ? 'text-blue-500 dark:text-sky-500' : '',
|
||||
focus ? 'bg-slate-50 dark:bg-slate-600/30' : '',
|
||||
'group flex w-full items-center rounded-md px-2 py-2 text-sm'
|
||||
)}
|
||||
onClick={() => setTheme('system')}
|
||||
>
|
||||
<ComputerDesktopIcon
|
||||
className="mr-2 h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
System
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<button
|
||||
data-cy="light-theme-button"
|
||||
className={classNames(
|
||||
theme === 'light' ? 'text-blue-500 dark:text-sky-500' : '',
|
||||
focus ? 'bg-slate-50 dark:bg-slate-600/30' : '',
|
||||
'group flex w-full items-center rounded-md px-2 py-2 text-sm'
|
||||
)}
|
||||
onClick={() => setTheme('light')}
|
||||
>
|
||||
<SunIcon className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Light
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<button
|
||||
data-cy="dark-theme-button"
|
||||
className={classNames(
|
||||
theme === 'dark' ? 'text-blue-500 dark:text-sky-500' : '',
|
||||
focus ? 'bg-slate-50 dark:bg-slate-600/30' : '',
|
||||
'group flex w-full items-center rounded-md px-2 py-2 text-sm'
|
||||
)}
|
||||
onClick={() => setTheme('dark')}
|
||||
>
|
||||
<MoonIcon className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Dark
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { getEnvironmentConfig } from '@nx/graph-shared';
|
||||
import { RenderTheme } from '@nx/graph';
|
||||
|
||||
const htmlEl = document.documentElement;
|
||||
export const localStorageThemeKey = 'nx-dep-graph-theme';
|
||||
export type Theme = RenderTheme | 'system';
|
||||
export let currentTheme: Theme;
|
||||
|
||||
export let mediaListener: ((ev: MediaQueryListEvent) => void) | undefined =
|
||||
undefined;
|
||||
export let vscodeDarkOberserver: MutationObserver | undefined = undefined;
|
||||
|
||||
// listen for (prefers-color-scheme: dark) changes
|
||||
function mediaListenerFactory(
|
||||
setResolvedTheme: (resolvedTheme: RenderTheme) => void
|
||||
) {
|
||||
return (ev: MediaQueryListEvent) => {
|
||||
const resolver = ev.matches ? 'dark' : 'light';
|
||||
toggleHtmlClass(resolver);
|
||||
currentTheme = resolver;
|
||||
setResolvedTheme(resolver);
|
||||
};
|
||||
}
|
||||
|
||||
// listen for body.vscode-dark changes
|
||||
function vscodeDarkOberserverFactory(
|
||||
setResolvedTheme: (resolvedTheme: RenderTheme) => void
|
||||
) {
|
||||
return new MutationObserver((mutations) => {
|
||||
for (let mutation of mutations) {
|
||||
if (mutation.type === 'attributes') {
|
||||
const isVSCodeDark = document.body.classList.contains('vscode-dark');
|
||||
const isVSCodeLight = document.body.classList.contains('vscode-light');
|
||||
if (!isVSCodeDark && !isVSCodeLight) {
|
||||
return;
|
||||
}
|
||||
const resolver = isVSCodeDark ? 'dark' : 'light';
|
||||
toggleHtmlClass(resolver);
|
||||
currentTheme = resolver;
|
||||
|
||||
setResolvedTheme(resolver);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleHtmlClass(theme: Theme) {
|
||||
if (theme === 'dark') {
|
||||
htmlEl.classList.add('dark');
|
||||
htmlEl.classList.remove('light');
|
||||
} else {
|
||||
htmlEl.classList.add('light');
|
||||
htmlEl.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
|
||||
export function getSystemTheme(): 'light' | 'dark' {
|
||||
const isVSCodeDark = document.body.classList.contains('vscode-dark');
|
||||
const isVSCodeLight = document.body.classList.contains('vscode-light');
|
||||
if (isVSCodeDark || isVSCodeLight) {
|
||||
return isVSCodeDark ? 'dark' : 'light';
|
||||
}
|
||||
// we don't want to use system theme in nx-console because it might conflict with the IDE theme
|
||||
if (getEnvironmentConfig().environment === 'nx-console') {
|
||||
return 'light';
|
||||
}
|
||||
const isDarkMedia = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
return isDarkMedia || isVSCodeDark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export function themeResolver(
|
||||
theme: Theme,
|
||||
setResolvedTheme: (resolvedTheme: RenderTheme) => void
|
||||
) {
|
||||
if (!('matchMedia' in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mediaListener) {
|
||||
mediaListener = mediaListenerFactory(setResolvedTheme);
|
||||
}
|
||||
|
||||
if (!vscodeDarkOberserver) {
|
||||
vscodeDarkOberserver = vscodeDarkOberserverFactory(setResolvedTheme);
|
||||
}
|
||||
|
||||
const darkMedia = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
if (theme !== 'system') {
|
||||
darkMedia.removeEventListener('change', mediaListener);
|
||||
vscodeDarkOberserver.disconnect();
|
||||
toggleHtmlClass(theme);
|
||||
currentTheme = theme;
|
||||
} else {
|
||||
const resolver = getSystemTheme();
|
||||
|
||||
if (getEnvironmentConfig().environment !== 'nx-console') {
|
||||
darkMedia.addEventListener('change', mediaListener);
|
||||
}
|
||||
vscodeDarkOberserver.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
toggleHtmlClass(resolver);
|
||||
currentTheme = resolver;
|
||||
}
|
||||
|
||||
localStorage.setItem(localStorageThemeKey, theme);
|
||||
setResolvedTheme(currentTheme);
|
||||
|
||||
return currentTheme;
|
||||
}
|
||||
Reference in New Issue
Block a user