Skip to content

Commit 5ef9b16

Browse files
authored
feat: add ThemeColors component to display theme color matrix from API (#553) [skip ci]
1 parent eec9ce9 commit 5ef9b16

File tree

2 files changed

+273
-0
lines changed

2 files changed

+273
-0
lines changed
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
import { useEffect, useMemo, useState } from 'react';
3+
import { DialErrorText } from '@/components/ErrorText/ErrorText';
4+
import { DialLabelledText } from '@/components/LabelledText/LabelledText';
5+
import { DialLoader } from '@/components/Loader/Loader';
6+
import { DialNoDataContent } from '@/components/NoDataContent/NoDataContent';
7+
import { DialTag } from '@/components/Tag/Tag';
8+
9+
interface ThemeVariables {
10+
id?: string;
11+
displayName?: string;
12+
colors?: Record<string, string>;
13+
topicColors?: Record<string, string>;
14+
authColors?: Record<string, string>;
15+
}
16+
17+
interface ThemesListingResponse {
18+
themes?: ThemeVariables[];
19+
}
20+
21+
interface ColorRow {
22+
token: string;
23+
}
24+
25+
const THEMES_API_URL = import.meta.env.VITE_THEME_COLORS_API_URL;
26+
27+
const createRows = (
28+
darkTokens: Record<string, string>,
29+
lightTokens: Record<string, string>,
30+
): ColorRow[] => {
31+
const darkOrder = Object.keys(darkTokens);
32+
const lightExtra = Object.keys(lightTokens).filter(
33+
(token) => !darkTokens[token],
34+
);
35+
36+
return [...darkOrder, ...lightExtra].map((token) => ({ token }));
37+
};
38+
39+
const toThemesArray = (data: unknown): ThemeVariables[] => {
40+
if (Array.isArray(data)) {
41+
return data as ThemeVariables[];
42+
}
43+
44+
if (data && typeof data === 'object' && 'themes' in data) {
45+
return (data as ThemesListingResponse).themes ?? [];
46+
}
47+
48+
return [];
49+
};
50+
51+
const findTheme = (themes: ThemeVariables[], key: 'dark' | 'light') =>
52+
themes.find((theme) => theme.id?.toLowerCase() === key) ??
53+
themes.find((theme) => theme.displayName?.toLowerCase() === key);
54+
55+
const getColorValue = (
56+
tokens: Record<string, string> | undefined,
57+
token: string,
58+
) => tokens?.[token] ?? 'N/A';
59+
60+
const ColorValue = ({ value }: { value: string }) => (
61+
<div className="inline-flex items-center gap-2">
62+
<span
63+
className="h-4 w-4 shrink-0 rounded-sm border border-primary"
64+
style={{
65+
backgroundColor: value !== 'N/A' ? value : 'transparent',
66+
}}
67+
/>
68+
<DialTag tag={value} bordered={false} className="bg-layer-2" />
69+
</div>
70+
);
71+
72+
const meta = {
73+
title: 'DIAL/Theme Colors',
74+
parameters: {
75+
layout: 'fullscreen',
76+
},
77+
} satisfies Meta;
78+
79+
export default meta;
80+
type Story = StoryObj<typeof meta>;
81+
82+
const ColorTable = ({
83+
title,
84+
rows,
85+
darkTokens,
86+
lightTokens,
87+
}: {
88+
title: string;
89+
rows: ColorRow[];
90+
darkTokens: Record<string, string>;
91+
lightTokens: Record<string, string>;
92+
}) => (
93+
<div className="mb-6 overflow-x-auto rounded border border-primary last:mb-0">
94+
<div className="border-b border-primary bg-layer-2 px-3 py-2 text-sm font-semibold">
95+
{title}
96+
</div>
97+
<div className="grid min-w-[760px] grid-cols-[minmax(280px,1fr)_minmax(220px,1fr)_minmax(220px,1fr)] text-sm">
98+
<div className="border-b border-r border-primary bg-layer-2 px-3 py-2 font-semibold">
99+
Token
100+
</div>
101+
<div className="border-b border-r border-primary bg-layer-2 px-3 py-2 font-semibold">
102+
Dark
103+
</div>
104+
<div className="border-b border-primary bg-layer-2 px-3 py-2 font-semibold">
105+
Light
106+
</div>
107+
{rows.map((row) => {
108+
const darkValue = getColorValue(darkTokens, row.token);
109+
const lightValue = getColorValue(lightTokens, row.token);
110+
111+
return (
112+
<div key={row.token} className="contents">
113+
<div className="border-b border-r border-primary bg-layer-1 px-3 py-2">
114+
{row.token}
115+
</div>
116+
<div className="border-b border-r border-primary bg-layer-1 px-3 py-2">
117+
<ColorValue value={darkValue} />
118+
</div>
119+
<div className="border-b border-primary bg-layer-1 px-3 py-2">
120+
<ColorValue value={lightValue} />
121+
</div>
122+
</div>
123+
);
124+
})}
125+
</div>
126+
</div>
127+
);
128+
129+
const ThemeColorsTable = () => {
130+
const [themes, setThemes] = useState<ThemeVariables[]>([]);
131+
const [loading, setLoading] = useState(true);
132+
const [error, setError] = useState<string | null>(null);
133+
134+
useEffect(() => {
135+
let isMounted = true;
136+
137+
const loadThemes = async () => {
138+
if (!THEMES_API_URL) {
139+
setError('THEMES_API_URL is not defined in environment variables');
140+
setLoading(false);
141+
return;
142+
}
143+
try {
144+
const response = await fetch(THEMES_API_URL);
145+
if (!response.ok) {
146+
throw new Error(`HTTP ${response.status}`);
147+
}
148+
149+
const data = (await response.json()) as unknown;
150+
const parsedThemes = toThemesArray(data);
151+
152+
if (isMounted) {
153+
setThemes(parsedThemes);
154+
}
155+
} catch (e) {
156+
if (isMounted) {
157+
setError(e instanceof Error ? e.message : 'Failed to load themes');
158+
}
159+
} finally {
160+
if (isMounted) {
161+
setLoading(false);
162+
}
163+
}
164+
};
165+
166+
loadThemes();
167+
168+
return () => {
169+
isMounted = false;
170+
};
171+
}, []);
172+
173+
const darkTheme = useMemo(
174+
() => findTheme(themes, 'dark') ?? themes[0],
175+
[themes],
176+
);
177+
const lightTheme = useMemo(
178+
() => findTheme(themes, 'light') ?? themes[1],
179+
[themes],
180+
);
181+
182+
const colorRows = useMemo(
183+
() => createRows(darkTheme?.colors ?? {}, lightTheme?.colors ?? {}),
184+
[darkTheme, lightTheme],
185+
);
186+
const topicRows = useMemo(
187+
() =>
188+
createRows(darkTheme?.topicColors ?? {}, lightTheme?.topicColors ?? {}),
189+
[darkTheme, lightTheme],
190+
);
191+
const authRows = useMemo(
192+
() => createRows(darkTheme?.authColors ?? {}, lightTheme?.authColors ?? {}),
193+
[darkTheme, lightTheme],
194+
);
195+
196+
if (loading) {
197+
return (
198+
<div className="flex h-[180px] items-center justify-center">
199+
<DialLoader fullWidth={false} />
200+
</div>
201+
);
202+
}
203+
204+
if (error) {
205+
return (
206+
<div className="p-4">
207+
<DialErrorText
208+
errorText={`Failed to load theme colors from API: ${error}`}
209+
/>
210+
</div>
211+
);
212+
}
213+
214+
if (!darkTheme || !lightTheme) {
215+
return (
216+
<DialNoDataContent
217+
title="Dark/Light theme data is missing"
218+
description="Could not resolve both themes from API response."
219+
containerClassName="min-h-[180px]"
220+
/>
221+
);
222+
}
223+
224+
return (
225+
<div className="w-full p-4 text-primary">
226+
<div className="mb-3">
227+
<DialLabelledText
228+
label="Themes API URL"
229+
text={THEMES_API_URL}
230+
className="max-w-none"
231+
/>
232+
</div>
233+
<ColorTable
234+
title="colors"
235+
rows={colorRows}
236+
darkTokens={darkTheme.colors ?? {}}
237+
lightTokens={lightTheme.colors ?? {}}
238+
/>
239+
<ColorTable
240+
title="topicColors"
241+
rows={topicRows}
242+
darkTokens={darkTheme.topicColors ?? {}}
243+
lightTokens={lightTheme.topicColors ?? {}}
244+
/>
245+
<ColorTable
246+
title="authColors"
247+
rows={authRows}
248+
darkTokens={darkTheme.authColors ?? {}}
249+
lightTokens={lightTheme.authColors ?? {}}
250+
/>
251+
</div>
252+
);
253+
};
254+
255+
export const FromThemesApi: Story = {
256+
render: () => <ThemeColorsTable />,
257+
parameters: {
258+
docs: {
259+
description: {
260+
story: 'Theme color matrix loaded from themes API (`config.json`).',
261+
},
262+
},
263+
},
264+
};

src/types/vite-env.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/// <reference types="vite/client" />
2+
3+
interface ImportMetaEnv {
4+
readonly VITE_THEME_COLORS_API_URL?: string;
5+
}
6+
7+
interface ImportMeta {
8+
readonly env: ImportMetaEnv;
9+
}

0 commit comments

Comments
 (0)