Переглянути джерело

fix(frontend): preserve theme body classes (#6157)

* fix(storybook): preserve preview body classes

* fix(frontend): retain theme body classes

* fix(storybook): mirror panel theme attributes

* test(storybook): cover theme switches

* test(storybook): strengthen theme DOM coverage

* fix(frontend): preserve message container classes

---------

Co-authored-by: PathGao <[email protected]>
PathGao 12 годин тому
батько
коміт
8d02ae28f5

+ 6 - 5
frontend/.storybook/preview.tsx

@@ -1,4 +1,4 @@
-import { useEffect } from 'react';
+import { useLayoutEffect } from 'react';
 import type { Decorator, Preview } from '@storybook/react-vite';
 import { ConfigProvider } from 'antd';
 import i18next from 'i18next';
@@ -17,11 +17,12 @@ if (!i18next.isInitialized) {
   });
 }
 
-const withTheme: Decorator = (Story, context) => {
+export const withTheme: Decorator = (Story, context) => {
   const dark = context.globals.theme === 'dark';
-  useEffect(() => {
-    document.body.setAttribute('class', dark ? 'dark' : 'light');
-    document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
+  useLayoutEffect(() => {
+    document.body.classList.remove('dark', 'light');
+    document.body.classList.add(dark ? 'dark' : 'light');
+    document.documentElement.removeAttribute('data-theme');
   }, [dark]);
   return (
     <ConfigProvider theme={buildAntdThemeConfig(dark, false)}>

+ 8 - 4
frontend/src/hooks/useTheme.tsx

@@ -1,4 +1,4 @@
-import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
+import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState } from 'react';
 import type { ReactNode } from 'react';
 import { theme as antdTheme } from 'antd';
 import type { ThemeConfig } from 'antd';
@@ -13,14 +13,18 @@ function readBool(key: string, fallback: boolean): boolean {
 }
 
 function applyDom(isDark: boolean, isUltra: boolean) {
-  document.body.setAttribute('class', isDark ? 'dark' : 'light');
+  document.body.classList.remove('dark', 'light');
+  document.body.classList.add(isDark ? 'dark' : 'light');
   if (isUltra) {
     document.documentElement.setAttribute('data-theme', 'ultra-dark');
   } else {
     document.documentElement.removeAttribute('data-theme');
   }
   const msg = document.getElementById('message');
-  if (msg) msg.className = isDark ? 'dark' : 'light';
+  if (msg) {
+    msg.classList.remove('dark', 'light');
+    msg.classList.add(isDark ? 'dark' : 'light');
+  }
 }
 
 // module load so the document is in the right theme before React mounts.
@@ -158,7 +162,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
   const [isDark, setIsDark] = useState<boolean>(initialDark);
   const [isUltra, setIsUltra] = useState<boolean>(initialUltra);
 
-  useEffect(() => {
+  useLayoutEffect(() => {
     applyDom(isDark, isUltra);
     localStorage.setItem(STORAGE_DARK, String(isDark));
     localStorage.setItem(STORAGE_ULTRA, String(isUltra));

+ 51 - 0
frontend/src/test/storybook-theme.test.tsx

@@ -0,0 +1,51 @@
+import { render } from '@testing-library/react';
+import { afterEach, expect, test } from 'vitest';
+
+import { withTheme } from '../../.storybook/preview';
+import { ThemeProvider } from '@/hooks/useTheme';
+
+function Story() {
+  return <div>Story</div>;
+}
+
+function StorybookTheme({ theme }: { theme: 'light' | 'dark' }) {
+  return withTheme(Story, { globals: { theme } } as Partial<Parameters<typeof withTheme>[1]> as Parameters<typeof withTheme>[1]);
+}
+
+afterEach(() => {
+  document.body.className = '';
+  document.documentElement.removeAttribute('data-theme');
+});
+
+test('preserves unrelated body classes when applying the Storybook theme', () => {
+  document.body.className = 'storybook-fixture dark';
+  document.documentElement.setAttribute('data-theme', 'ultra-dark');
+  const { rerender } = render(<StorybookTheme theme="light" />);
+
+  expect(document.body.classList.contains('storybook-fixture')).toBe(true);
+  expect(document.body.classList.contains('light')).toBe(true);
+  expect(document.body.classList.contains('dark')).toBe(false);
+  expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
+
+  rerender(<StorybookTheme theme="dark" />);
+
+  expect(document.body.classList.contains('storybook-fixture')).toBe(true);
+  expect(document.body.classList.contains('dark')).toBe(true);
+  expect(document.body.classList.contains('light')).toBe(false);
+});
+
+test('preserves unrelated body classes when applying the panel theme', () => {
+  document.body.className = 'panel-fixture';
+  const message = document.createElement('div');
+  message.id = 'message';
+  message.className = 'message-fixture';
+  document.body.append(message);
+
+  render(<ThemeProvider><div>Panel</div></ThemeProvider>);
+
+  expect(document.body.classList.contains('panel-fixture')).toBe(true);
+  expect(document.body.classList.contains('dark')).toBe(true);
+  expect(document.body.classList.contains('light')).toBe(false);
+  expect(message.classList.contains('message-fixture')).toBe(true);
+  expect(message.classList.contains('dark')).toBe(true);
+});