feat: Implement useThemeLabelColor hook and update components to utilize it for label color management

This commit is contained in:
cita
2026-03-08 13:07:27 +08:00
parent 8e291b9bf8
commit 4172ad4297
5 changed files with 118 additions and 28 deletions
@@ -65,4 +65,30 @@ describe('ModelAnalysisPanel token summaries', () => {
root?.unmount();
});
it('renders with fallback label color when browser theme APIs are unavailable', async () => {
globalThis.document = {
documentElement: {},
} as unknown as Document;
delete (globalThis as typeof globalThis & { getComputedStyle?: typeof getComputedStyle }).getComputedStyle;
delete (globalThis as typeof globalThis & { MutationObserver?: typeof MutationObserver }).MutationObserver;
let root: ReturnType<typeof create> | null = null;
await expect(act(async () => {
root = create(
<ModelAnalysisPanel
data={{
totals: {
spend: 0.123456,
calls: 10,
tokens: 611_540_335,
},
}}
/>,
);
})).resolves.toBeUndefined();
root?.unmount();
});
});
+3 -14
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { VChart } from '@visactor/react-vchart';
import { InlineBrandIcon } from './BrandIcon.js';
import { formatCompactTokenMetric } from '../numberFormat.js';
import { useThemeLabelColor } from './useThemeLabelColor.js';
type TabKey = 'spend' | 'trend' | 'calls' | 'rank';
@@ -56,19 +57,7 @@ function EmptyBlock() {
export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
const [activeTab, setActiveTab] = useState<TabKey>('spend');
/* VChart uses Canvas — CSS variables don't work. Read the computed value. */
const [labelColor, setLabelColor] = useState('#9ca3af');
useEffect(() => {
const read = () => {
const c = getComputedStyle(document.documentElement).getPropertyValue('--color-text-secondary').trim();
if (c) setLabelColor(c);
};
read();
const obs = new MutationObserver(read);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => obs.disconnect();
}, []);
const labelColor = useThemeLabelColor();
const totals = {
spend: toSafeNumber(data?.totals?.spend),
@@ -0,0 +1,51 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import SiteDistributionChart from './SiteDistributionChart.js';
vi.mock('@visactor/react-vchart', () => ({
VChart: () => null,
}));
describe('SiteDistributionChart', () => {
const originalDocument = globalThis.document;
const originalGetComputedStyle = globalThis.getComputedStyle;
const originalMutationObserver = globalThis.MutationObserver;
beforeEach(() => {
globalThis.document = {
documentElement: {
getAttribute: vi.fn(),
},
} as unknown as Document;
delete (globalThis as typeof globalThis & { getComputedStyle?: typeof getComputedStyle }).getComputedStyle;
delete (globalThis as typeof globalThis & { MutationObserver?: typeof MutationObserver }).MutationObserver;
});
afterEach(() => {
globalThis.document = originalDocument;
globalThis.getComputedStyle = originalGetComputedStyle;
globalThis.MutationObserver = originalMutationObserver;
});
it('renders with fallback label color when browser theme APIs are unavailable', async () => {
let renderer: ReturnType<typeof create> | null = null;
await expect(act(async () => {
renderer = create(
<SiteDistributionChart
data={[
{
siteName: 'Demo Site',
platform: 'demo',
totalBalance: 12.34,
totalSpend: 1.23,
accountCount: 2,
},
]}
/>,
);
})).resolves.toBeUndefined();
renderer?.unmount();
});
});
@@ -1,5 +1,6 @@
import React, { useState, useMemo } from 'react';
import { useState, useMemo } from 'react';
import { VChart } from '@visactor/react-vchart';
import { useThemeLabelColor } from '../useThemeLabelColor.js';
interface SiteDistributionData {
siteName: string;
@@ -86,6 +87,7 @@ function EmptyState() {
export default function SiteDistributionChart({ data, loading }: SiteDistributionChartProps) {
const [viewMode, setViewMode] = useState<ViewMode>('balance');
const labelColor = useThemeLabelColor();
const chartData = useMemo(() => {
if (!data || data.length === 0) return [];
@@ -99,19 +101,6 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
const hasData = chartData.length > 0 && chartData.some((d) => d.value > 0);
/* VChart uses Canvas — CSS variables don't work for label colors. Read the computed value. */
const [labelColor, setLabelColor] = useState('#9ca3af');
React.useEffect(() => {
const read = () => {
const c = getComputedStyle(document.documentElement).getPropertyValue('--color-text-secondary').trim();
if (c) setLabelColor(c);
};
read();
const obs = new MutationObserver(read);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => obs.disconnect();
}, []);
const PIE_COLORS = ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
const spec = useMemo(() => {
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react';
function readThemeLabelColor(): string | null {
if (typeof document === 'undefined') return null;
const root = document.documentElement;
if (!root || typeof globalThis.getComputedStyle !== 'function') return null;
const color = globalThis.getComputedStyle(root).getPropertyValue('--color-text-secondary').trim();
return color || null;
}
export function useThemeLabelColor(fallback = '#9ca3af'): string {
const [labelColor, setLabelColor] = useState(fallback);
useEffect(() => {
const root = typeof document !== 'undefined' ? document.documentElement : null;
const read = () => {
const color = readThemeLabelColor();
if (color) setLabelColor(color);
};
read();
if (!root || typeof globalThis.MutationObserver !== 'function') {
return undefined;
}
const observer = new globalThis.MutationObserver(read);
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] });
return () => observer.disconnect();
}, []);
return labelColor;
}