Compare commits

...

2 Commits

Author SHA1 Message Date
Gaurav Tewari
f082821ac2 feat: add light and dark mode in command pallet 2026-05-25 13:30:44 +05:30
Gaurav Tewari
3c5bd81421 chore: add ripple effect 2026-05-23 22:15:00 +05:30
4 changed files with 152 additions and 17 deletions

View File

@@ -10,8 +10,14 @@ import {
} from '@signozhq/ui/command';
import logEvent from 'api/common/logEvent';
import { useThemeMode } from 'hooks/useDarkMode';
import { THEME_MODE } from 'hooks/useDarkMode/constant';
import history from 'lib/history';
import { ROLES as UserRole } from 'types/roles';
import {
canAnimateThemeRipple,
getRippleOrigin,
runThemeRipple,
} from 'utils/themeRipple';
import { createShortcutActions } from '../../constants/shortcutActions';
import { useCmdK } from '../../providers/cmdKProvider';
@@ -66,12 +72,31 @@ export function CmdKPalette({
function handleThemeChange(value: string): void {
logEvent('Account Settings: Theme Changed', { theme: value });
if (value === 'auto') {
setAutoSwitch(true);
} else {
setAutoSwitch(false);
setTheme(value);
const currentIsDark = theme === THEME_MODE.DARK;
const targetIsDark = value === THEME_MODE.DARK;
const willFlipDarkMode =
value !== THEME_MODE.SYSTEM && targetIsDark !== currentIsDark;
const applyChange = (): void => {
if (value === THEME_MODE.SYSTEM) {
setAutoSwitch(true);
} else {
setAutoSwitch(false);
setTheme(value);
}
setOpen(false);
};
if (!willFlipDarkMode || !canAnimateThemeRipple()) {
applyChange();
return;
}
const activeItem = document.querySelector<HTMLElement>(
'[cmdk-item][data-selected="true"]',
);
runThemeRipple(getRippleOrigin(activeItem), applyChange);
}
function onClickHandler(key: string): void {

View File

@@ -14,6 +14,12 @@ import { useAppContext } from 'providers/App/App';
import { UserPreference } from 'types/api/preferences/preference';
import { showErrorNotification } from 'utils/error';
import {
canAnimateThemeRipple,
getRippleOrigin,
runThemeRipple,
} from 'utils/themeRipple';
import LicenseSection from './LicenseSection';
import TimezoneAdaptation from './TimezoneAdaptation/TimezoneAdaptation';
import UserInfo from './UserInfo';
@@ -88,22 +94,35 @@ function MySettings(): JSX.Element {
return isDarkMode ? 'dark' : 'light';
});
const handleThemeChange = ({ target: { value } }: RadioChangeEvent): void => {
logEvent('Account Settings: Theme Changed', {
theme: value,
});
setTheme(value);
const handleThemeChange = (event: RadioChangeEvent): void => {
const { value } = event.target;
logEvent('Account Settings: Theme Changed', { theme: value });
if (value === 'auto') {
setAutoSwitch(true);
} else {
const willFlipDarkMode =
value !== 'auto' && (value === 'dark') !== isDarkMode;
const applyChange = (): void => {
setTheme(value);
if (value === 'auto') {
setAutoSwitch(true);
return;
}
setAutoSwitch(false);
// Only toggle if the current theme is different from the target
const targetIsDark = value === 'dark';
if (targetIsDark !== isDarkMode) {
if (willFlipDarkMode) {
toggleTheme();
}
};
// Only ripple on a real dark↔light flip, and only when the browser
// supports View Transitions and the user hasn't opted out of motion.
if (!willFlipDarkMode || !canAnimateThemeRipple()) {
applyChange();
return;
}
const clickedTarget = event.nativeEvent?.target as HTMLElement | null;
const clickedButton = clickedTarget?.closest('.ant-radio-button-wrapper');
runThemeRipple(getRippleOrigin(clickedButton), applyChange);
};
useEffect(() => {
@@ -143,7 +162,7 @@ function MySettings(): JSX.Element {
value: checked,
},
{
onError: (error) => {
onError: (error: unknown) => {
// Revert the state if the API call fails
setSideNavPinned(!checked);
updateUserPreferenceInContext({

View File

@@ -826,3 +826,18 @@ body.ai-assistant-panel-open {
:root {
--input-focus-outline-width: 0;
}
// Disable default fade so the JS-driven clip-path theme ripple isn't crossfaded.
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 2;
}

View File

@@ -0,0 +1,76 @@
import { flushSync } from 'react-dom';
const RIPPLE_DURATION_MS = 500;
const RIPPLE_EASING = 'ease-in-out';
type ViewTransition = { ready: Promise<void> };
type DocumentWithVT = Document & {
startViewTransition?: (callback: () => void) => ViewTransition;
};
export type RippleOrigin = { x: number; y: number };
export function getRippleOrigin(
element: Element | null | undefined,
): RippleOrigin {
const rect = element?.getBoundingClientRect();
if (!rect) {
return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
}
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
}
export function canAnimateThemeRipple(): boolean {
const doc = document as DocumentWithVT;
if (typeof doc.startViewTransition !== 'function') {
return false;
}
return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
// Runs `applyChange` inside a View Transition and grows a circular clip-path
// from `origin` until it covers the viewport. Callers should gate on
// canAnimateThemeRipple() first; this is a safe no-animation fallback otherwise.
export function runThemeRipple(
origin: RippleOrigin,
applyChange: () => void,
): void {
const doc = document as DocumentWithVT;
if (!doc.startViewTransition) {
applyChange();
return;
}
const transition = doc.startViewTransition(() => {
flushSync(applyChange);
});
const endRadius = distanceToFurthestCorner(origin);
const fromCircle = `circle(0px at ${origin.x}px ${origin.y}px)`;
const toCircle = `circle(${endRadius}px at ${origin.x}px ${origin.y}px)`;
transition.ready
.then(() => {
document.documentElement.animate(
{ clipPath: [fromCircle, toCircle] },
{
duration: RIPPLE_DURATION_MS,
easing: RIPPLE_EASING,
pseudoElement: '::view-transition-new(root)',
},
);
})
.catch(() => {
// Transition cancelled — applyChange has already run, so nothing to do.
});
}
function distanceToFurthestCorner({ x, y }: RippleOrigin): number {
return Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
);
}