0f451897cf
**What** - Add `useTheme()` and `useThemeColorScheme()` as the public theme accessors, and migrate the 88 internal `useContext(ThemeContext)` call sites to them. `ThemeContext` stays exported. - Make `ThemeProvider` overridable and scopeable: new `applyToRoot` (default `true`), `overrides` (a `--t-*` map), and `className` props. When scoping is requested it renders a `display: contents` wrapper that also serves as the themed portal container, exposed via `ThemeScopeContext` / `useThemeContainer()`. `AppTooltip` and `Modal` portal into that container, falling back to `document.body`. - Document the `--t-*` override contract in the README; barrels regenerated. **Why** Consumers had no stable theme accessor (they reached into the raw context) and no supported way to re-theme. This adds both without behavior change. **Reviewer notes** - The default path is unchanged: `applyToRoot` defaults to `true`, so the colorScheme class still lands on `<html>` and portaled overlays (tooltips, dropdowns, modals) stay themed. The global class is load-bearing for body portals; scoping is opt-in. - `twenty-front` is untouched (migrating its consumers is a separate follow-up). <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22007?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
175 lines
4.6 KiB
TypeScript
175 lines
4.6 KiB
TypeScript
import { clsx } from 'clsx';
|
|
import { createContext, useLayoutEffect, useRef, useState } from 'react';
|
|
|
|
import { isDefined } from '@ui/utilities/utils/isDefined';
|
|
|
|
import { ThemeScopeContext } from './ThemeScopeContext';
|
|
import { themeCssVariables } from './themeCssVariables';
|
|
|
|
type StringLeaves<T> = {
|
|
[K in keyof T]: T[K] extends string ? string : StringLeaves<T[K]>;
|
|
};
|
|
|
|
type DeepMerge<T, U> = {
|
|
[K in keyof T]: K extends keyof U
|
|
? U[K] extends Record<string, unknown>
|
|
? T[K] extends Record<string, unknown>
|
|
? DeepMerge<T[K], U[K]>
|
|
: U[K]
|
|
: U[K]
|
|
: T[K];
|
|
};
|
|
|
|
// CSS variables that resolve to pure numbers at runtime
|
|
type NumericOverrides = {
|
|
icon: {
|
|
size: { sm: number; md: number; lg: number; xl: number };
|
|
stroke: { sm: number; md: number; lg: number };
|
|
};
|
|
animation: {
|
|
duration: { instant: number; fast: number; normal: number; slow: number };
|
|
};
|
|
text: {
|
|
lineHeight: { lg: number; md: number };
|
|
iconSizeMedium: number;
|
|
iconSizeSmall: number;
|
|
iconStrikeLight: number;
|
|
iconStrikeMedium: number;
|
|
iconStrikeBold: number;
|
|
};
|
|
spacingMultiplicator: number;
|
|
lastLayerZIndex: number;
|
|
};
|
|
|
|
export type ThemeType = DeepMerge<
|
|
StringLeaves<typeof themeCssVariables>,
|
|
NumericOverrides
|
|
>;
|
|
|
|
export type ThemeContextType = {
|
|
theme: ThemeType;
|
|
colorScheme: 'light' | 'dark';
|
|
};
|
|
|
|
export type ThemeOverrides = Record<string, string | number>;
|
|
|
|
const computeThemeFromCss = (sourceElement?: HTMLElement): ThemeType => {
|
|
if (
|
|
typeof document === 'undefined' ||
|
|
typeof getComputedStyle !== 'function'
|
|
) {
|
|
return themeCssVariables as unknown as ThemeType;
|
|
}
|
|
|
|
const computedStyle = getComputedStyle(
|
|
sourceElement ?? document.documentElement,
|
|
);
|
|
|
|
const resolve = (obj: Record<string, unknown>): Record<string, unknown> => {
|
|
const result: Record<string, unknown> = {};
|
|
|
|
for (const key of Object.keys(obj)) {
|
|
const value = obj[key];
|
|
|
|
if (typeof value === 'string' && value.startsWith('var(')) {
|
|
const varName = value.slice(4, -1);
|
|
const raw = computedStyle.getPropertyValue(varName).trim();
|
|
const num = Number(raw);
|
|
result[key] = raw !== '' && !isNaN(num) ? num : raw;
|
|
} else if (typeof value === 'object' && value !== null) {
|
|
result[key] = resolve(value as Record<string, unknown>);
|
|
} else {
|
|
result[key] = value;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
return resolve(
|
|
themeCssVariables as unknown as Record<string, unknown>,
|
|
) as unknown as ThemeType;
|
|
};
|
|
|
|
const applyColorSchemeClass = (colorScheme: 'light' | 'dark') => {
|
|
if (typeof document === 'undefined') return;
|
|
const root = document.documentElement;
|
|
if (!root?.classList) return;
|
|
root.classList.toggle('dark', colorScheme === 'dark');
|
|
root.classList.toggle('light', colorScheme === 'light');
|
|
};
|
|
|
|
export const ThemeContext = createContext<ThemeContextType>({
|
|
theme: themeCssVariables as unknown as ThemeType,
|
|
colorScheme: 'light',
|
|
});
|
|
|
|
export const ThemeProvider = ({
|
|
children,
|
|
colorScheme,
|
|
applyToRoot = true,
|
|
overrides,
|
|
className,
|
|
}: {
|
|
children: React.ReactNode;
|
|
colorScheme: 'light' | 'dark';
|
|
applyToRoot?: boolean;
|
|
overrides?: ThemeOverrides;
|
|
className?: string;
|
|
}) => {
|
|
const isScoped = isDefined(overrides) || !applyToRoot;
|
|
|
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
|
|
const [theme, setTheme] = useState<ThemeType>(() => {
|
|
if (applyToRoot) {
|
|
applyColorSchemeClass(colorScheme);
|
|
}
|
|
return computeThemeFromCss();
|
|
});
|
|
const [scopeContainer, setScopeContainer] = useState<HTMLElement | null>(
|
|
null,
|
|
);
|
|
|
|
const overridesKey = isDefined(overrides) ? JSON.stringify(overrides) : '';
|
|
|
|
useLayoutEffect(() => {
|
|
if (applyToRoot) {
|
|
applyColorSchemeClass(colorScheme);
|
|
}
|
|
|
|
setTheme(
|
|
computeThemeFromCss(
|
|
isScoped ? (wrapperRef.current ?? undefined) : undefined,
|
|
),
|
|
);
|
|
setScopeContainer(isScoped ? wrapperRef.current : null);
|
|
}, [colorScheme, applyToRoot, isScoped, overridesKey]);
|
|
|
|
const contextValue = { theme, colorScheme };
|
|
|
|
if (!isScoped) {
|
|
return (
|
|
<ThemeContext.Provider value={contextValue}>
|
|
{children}
|
|
</ThemeContext.Provider>
|
|
);
|
|
}
|
|
|
|
const overridesStyle = (overrides ?? {}) as React.CSSProperties;
|
|
|
|
return (
|
|
<ThemeContext.Provider value={contextValue}>
|
|
<ThemeScopeContext.Provider value={scopeContainer}>
|
|
<div
|
|
ref={wrapperRef}
|
|
className={clsx(applyToRoot ? undefined : colorScheme, className)}
|
|
style={{ display: 'contents', ...overridesStyle }}
|
|
>
|
|
{children}
|
|
</div>
|
|
</ThemeScopeContext.Provider>
|
|
</ThemeContext.Provider>
|
|
);
|
|
};
|