Skip to content

Commit 6ac11b3

Browse files
committed
feat(theme-generator): implement the component inside Storybook with corresponding stories
1 parent 88c8fdd commit 6ac11b3

File tree

64 files changed

+2431
-2
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

64 files changed

+2431
-2
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,5 +78,6 @@
7878
},
7979
"resolutions": {
8080
"@types/scheduler": "< 0.23.0"
81-
}
81+
},
82+
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
8283
}

packages/storybook/.storybook/main.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ const config: StorybookConfig = {
3232
${head}
3333
<link rel="stylesheet" type="text/css" href="css/preview.css" />
3434
`,
35-
staticDirs: ['../assets', ...getCodeEditorStaticDirs(__filename)],
35+
staticDirs: [
36+
'../assets',
37+
{ from: '../../themes/dist', to: '/themes' },
38+
...getCodeEditorStaticDirs(__filename)
39+
],
3640
stories: [
3741
'../stories/**/*.mdx',
3842
'../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)',
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import React, { type JSX, useEffect, useRef } from 'react';
2+
import styles from './storyGrid.module.css';
3+
4+
type StoryRef = {
5+
id: string,
6+
label?: string,
7+
};
8+
9+
type ComponentStories = {
10+
kind: string,
11+
name: string,
12+
stories: StoryRef[],
13+
};
14+
15+
type Props = {
16+
components: ComponentStories[],
17+
themeClass?: string,
18+
themeVariables?: Record<string, string>,
19+
};
20+
21+
function toTitleCase(text: string): string {
22+
return text
23+
.replace(/[-_]+/g, ' ')
24+
.replace(/\b\w/g, (char) => char.toUpperCase());
25+
}
26+
27+
function getStoryLabel(story: StoryRef): string {
28+
if (story.label) {
29+
return story.label;
30+
}
31+
32+
const parts = story.id.split('--');
33+
const suffix = parts.length > 1 ? parts[1] : story.id;
34+
return toTitleCase(suffix);
35+
}
36+
37+
function StoryGrid({ components, themeClass, themeVariables }: Props): JSX.Element {
38+
const iframeRefs = useRef<(HTMLIFrameElement | null)[]>([]);
39+
40+
const injectThemeIntoIframe = (iframe: HTMLIFrameElement) => {
41+
if (!iframe?.contentDocument || !themeVariables || !themeClass) return;
42+
43+
const styleId = 'theme-generator-variables';
44+
let styleElement = iframe.contentDocument.getElementById(styleId) as HTMLStyleElement;
45+
46+
if (!styleElement) {
47+
styleElement = iframe.contentDocument.createElement('style');
48+
styleElement.id = styleId;
49+
iframe.contentDocument.head.appendChild(styleElement);
50+
}
51+
52+
const cssText = `.${themeClass} {\n${Object.entries(themeVariables)
53+
.map(([key, value]) => ` ${key}: ${value};`)
54+
.join('\n')}\n}`;
55+
56+
styleElement.textContent = cssText;
57+
iframe.contentDocument.body.classList.add(themeClass);
58+
};
59+
60+
useEffect(() => {
61+
iframeRefs.current.forEach((iframe) => {
62+
if (iframe) injectThemeIntoIframe(iframe);
63+
});
64+
}, [themeVariables, themeClass]);
65+
return (
66+
<div>
67+
{
68+
components.map((component) => (
69+
<section key={ component.name }>
70+
<h3>
71+
{ component.name }
72+
</h3>
73+
74+
<div className={ styles.grid__section__items }>
75+
{
76+
component.stories.map((story, storyIndex) => (
77+
<div className={themeClass || "ods-custom-theme"} key={ story.id }>
78+
<div>
79+
<iframe
80+
ref={(el) => {
81+
const globalIndex = components.findIndex(c => c.name === component.name) * component.stories.length + storyIndex;
82+
iframeRefs.current[globalIndex] = el;
83+
}}
84+
allowFullScreen
85+
loading="lazy"
86+
src={ `iframe.html?id=${story.id}&viewMode=story${themeClass ? `&globals=themeClass:${themeClass}` : ''}` }
87+
style={ { border: 0, width: '100%' } }
88+
title={ `${component.name} - ${getStoryLabel(story)}` }
89+
onLoad={() => {
90+
const globalIndex = components.findIndex(c => c.name === component.name) * component.stories.length + storyIndex;
91+
const iframe = iframeRefs.current[globalIndex];
92+
if (iframe) injectThemeIntoIframe(iframe);
93+
}} />
94+
</div>
95+
</div>
96+
))
97+
}
98+
</div>
99+
</section>
100+
))
101+
}
102+
</div>
103+
);
104+
}
105+
106+
export {
107+
StoryGrid,
108+
};
109+
110+
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.grid__section__items {
2+
display: grid;
3+
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
4+
gap: 1rem;
5+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { Splitter } from '@ark-ui/react/splitter';
2+
import React, { type JSX, useEffect, useState } from 'react';
3+
import classNames from 'classnames';
4+
import * as ODSReact from '@ovhcloud/ods-react';
5+
import { ORIENTATION, OrientationSwitch } from '../sandbox/actions/OrientationSwitch';
6+
import styles from './themeGenerator.module.css';
7+
import { ThemeGeneratorTreeView } from './themeGeneratorTreeView/ThemeGeneratorTreeView';
8+
import { ThemeGeneratorPreview } from './themeGeneratorPreview/ThemeGeneratorPreview';
9+
import { parseCssVariables } from './useThemeGenerator';
10+
import { ThemeGeneratorModal } from './themeGeneratorModal/ThemeGeneratorModal';
11+
import { ThemeGeneratorJSON } from './themeGeneratorJSON/ThemeGeneratorJSON';
12+
13+
const ThemeGenerator = (): JSX.Element => {
14+
const [isFullscreen, setIsFullscreen] = useState(false);
15+
const [orientation, setOrientation] = useState(ORIENTATION.horizontal);
16+
const [selectedTheme, setSelectedTheme] = useState('default');
17+
const [editedVariables, setEditedVariables] = useState<Record<string, string>>({});
18+
const [isCustomTheme, setIsCustomTheme] = useState(false);
19+
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
20+
const [pendingTheme, setPendingTheme] = useState<string | null>(null);
21+
const [isJsonOpen, setIsJsonOpen] = useState(false);
22+
23+
useEffect(() => {
24+
const loadTheme = async () => {
25+
if (selectedTheme === 'custom') {
26+
setIsCustomTheme(true);
27+
return;
28+
}
29+
30+
try {
31+
console.log('Selected theme:', selectedTheme);
32+
33+
// Fetch the CSS file from the static directory
34+
// Path: /themes/{themeName}/index.css (exposed via staticDirs in main.ts)
35+
const response = await fetch(`/themes/${selectedTheme}/index.css`);
36+
37+
if (!response.ok) {
38+
throw new Error(`Failed to fetch theme: ${response.statusText}`);
39+
}
40+
41+
const cssContent = await response.text();
42+
43+
// Parse CSS variables
44+
const variables = parseCssVariables(cssContent);
45+
console.log('Parsed variables:', Object.keys(variables).length, 'variables found');
46+
setEditedVariables(variables);
47+
setIsCustomTheme(false);
48+
} catch (error) {
49+
console.error('Failed to load theme:', error);
50+
}
51+
};
52+
53+
loadTheme();
54+
}, [selectedTheme]);
55+
56+
57+
function onToggleFullscreen() {
58+
setIsFullscreen((v) => !v);
59+
}
60+
61+
function onVariableChange(name: string, value: string) {
62+
setEditedVariables((prev) => ({
63+
...prev,
64+
[name]: value,
65+
}));
66+
67+
// Automatically switch to custom theme when user edits a variable
68+
if (!isCustomTheme) {
69+
setSelectedTheme('custom');
70+
setIsCustomTheme(true);
71+
}
72+
}
73+
74+
return <div className={ classNames(
75+
styles['theme-generator'],
76+
{ [ styles['theme-generator--fullscreen']]: isFullscreen },
77+
)}>
78+
<div className={ styles['theme-generator__menu'] }>
79+
<div className={styles['theme-generator__menu__left']}>
80+
<ODSReact.Button
81+
variant={ ODSReact.BUTTON_VARIANT.ghost }
82+
onClick={ () => setIsJsonOpen(true) }>
83+
<ODSReact.Icon name={ODSReact.ICON_NAME.chevronLeftUnderscore} />
84+
JSON
85+
</ODSReact.Button>
86+
<ODSReact.Switch
87+
value={selectedTheme}
88+
onValueChange={(details: { value: string }) => {
89+
const next = details.value;
90+
const isLeavingCustom = isCustomTheme && next !== 'custom';
91+
92+
if (isLeavingCustom) {
93+
setPendingTheme(next);
94+
setIsConfirmOpen(true);
95+
return;
96+
}
97+
98+
setSelectedTheme(next);
99+
}}
100+
>
101+
<ODSReact.SwitchItem value="default">
102+
Default
103+
</ODSReact.SwitchItem>
104+
<ODSReact.SwitchItem value="developer">
105+
Developer
106+
</ODSReact.SwitchItem>
107+
<ODSReact.SwitchItem value="custom">
108+
Custom
109+
</ODSReact.SwitchItem>
110+
</ODSReact.Switch>
111+
</div>
112+
<div className={styles['theme-generator__menu__right']}>
113+
<OrientationSwitch
114+
onChange={ (value) => setOrientation(value) }
115+
orientation={ orientation } />
116+
117+
<ODSReact.Button
118+
onClick={ onToggleFullscreen }
119+
variant={ ODSReact.BUTTON_VARIANT.ghost }>
120+
<ODSReact.Icon name={ isFullscreen ? ODSReact.ICON_NAME.shrink : ODSReact.ICON_NAME.resize } />
121+
</ODSReact.Button>
122+
</div>
123+
</div>
124+
<Splitter.Root
125+
className={ styles['theme-generator__container'] }
126+
orientation={ orientation }
127+
panels={ [{ id: 'tree-view', minSize: 10 }, { id: 'preview', minSize: 10 }] }>
128+
<Splitter.Panel id="tree-view">
129+
<ThemeGeneratorTreeView
130+
variables={editedVariables}
131+
onVariableChange={onVariableChange} />
132+
</Splitter.Panel>
133+
134+
<Splitter.ResizeTrigger
135+
asChild
136+
aria-label="Resize"
137+
id="tree-view:preview">
138+
<ODSReact.Button
139+
className={ classNames(
140+
styles['theme-generator__container__resize'],
141+
{ [styles['theme-generator__container__resize--horizontal']]: orientation === ORIENTATION.horizontal },
142+
{ [styles['theme-generator__container__resize--vertical']]: orientation === ORIENTATION.vertical },
143+
)}
144+
color={ ODSReact.BUTTON_COLOR.neutral } />
145+
</Splitter.ResizeTrigger>
146+
147+
<Splitter.Panel id="preview">
148+
<div className={ styles['theme-generator__container__preview'] }>
149+
<ThemeGeneratorPreview themeVariables={editedVariables} />
150+
</div>
151+
</Splitter.Panel>
152+
</Splitter.Root>
153+
154+
<ThemeGeneratorModal
155+
open={ isConfirmOpen }
156+
targetTheme={ pendingTheme }
157+
onConfirm={() => {
158+
if (pendingTheme) {
159+
setSelectedTheme(pendingTheme);
160+
}
161+
setPendingTheme(null);
162+
setIsConfirmOpen(false);
163+
}}
164+
onCancel={() => {
165+
setPendingTheme(null);
166+
setIsConfirmOpen(false);
167+
}}
168+
/>
169+
170+
<ThemeGeneratorJSON
171+
open={ isJsonOpen }
172+
variables={ editedVariables }
173+
onClose={ () => setIsJsonOpen(false) }
174+
onReplace={(next) => {
175+
setEditedVariables(next);
176+
setSelectedTheme('custom');
177+
setIsCustomTheme(true);
178+
}}
179+
/>
180+
</div>
181+
}
182+
183+
export { ThemeGenerator };

0 commit comments

Comments
 (0)