Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,6 @@
},
"resolutions": {
"@types/scheduler": "< 0.23.0"
}
},
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove

}
6 changes: 5 additions & 1 deletion packages/storybook/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ const config: StorybookConfig = {
${head}
<link rel="stylesheet" type="text/css" href="css/preview.css" />
`,
staticDirs: ['../assets', ...getCodeEditorStaticDirs(__filename)],
staticDirs: [
'../assets',
{ from: '../../themes/dist', to: '/themes' },
...getCodeEditorStaticDirs(__filename)
],
stories: [
'../stories/**/*.mdx',
'../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)',
Expand Down
110 changes: 110 additions & 0 deletions packages/storybook/src/components/storyGrid/StoryGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React, { type JSX, useEffect, useRef } from 'react';
import styles from './storyGrid.module.css';

type StoryRef = {
id: string,
label?: string,
};

type ComponentStories = {
kind: string,
name: string,
stories: StoryRef[],
};

type Props = {
components: ComponentStories[],
themeClass?: string,
themeVariables?: Record<string, string>,
};

function toTitleCase(text: string): string {
return text
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase());
}

function getStoryLabel(story: StoryRef): string {
if (story.label) {
return story.label;
}

const parts = story.id.split('--');
const suffix = parts.length > 1 ? parts[1] : story.id;
return toTitleCase(suffix);
}

function StoryGrid({ components, themeClass, themeVariables }: Props): JSX.Element {
const iframeRefs = useRef<(HTMLIFrameElement | null)[]>([]);

const injectThemeIntoIframe = (iframe: HTMLIFrameElement) => {
if (!iframe?.contentDocument || !themeVariables || !themeClass) return;

const styleId = 'theme-generator-variables';
let styleElement = iframe.contentDocument.getElementById(styleId) as HTMLStyleElement;

if (!styleElement) {
styleElement = iframe.contentDocument.createElement('style');
styleElement.id = styleId;
iframe.contentDocument.head.appendChild(styleElement);
}

const cssText = `.${themeClass} {\n${Object.entries(themeVariables)
.map(([key, value]) => ` ${key}: ${value};`)
.join('\n')}\n}`;

styleElement.textContent = cssText;
iframe.contentDocument.body.classList.add(themeClass);
};

useEffect(() => {
iframeRefs.current.forEach((iframe) => {
if (iframe) injectThemeIntoIframe(iframe);
});
}, [themeVariables, themeClass]);
return (
<div>
{
components.map((component) => (
<section key={ component.name }>
<h3>
{ component.name }
</h3>

<div className={ styles.grid__section__items }>
{
component.stories.map((story, storyIndex) => (
<div className={themeClass || "ods-custom-theme"} key={ story.id }>
<div>
<iframe
ref={(el) => {
const globalIndex = components.findIndex(c => c.name === component.name) * component.stories.length + storyIndex;
iframeRefs.current[globalIndex] = el;
}}
allowFullScreen
loading="lazy"
src={ `iframe.html?id=${story.id}&viewMode=story${themeClass ? `&globals=themeClass:${themeClass}` : ''}` }
style={ { border: 0, width: '100%' } }
title={ `${component.name} - ${getStoryLabel(story)}` }
onLoad={() => {
const globalIndex = components.findIndex(c => c.name === component.name) * component.stories.length + storyIndex;
const iframe = iframeRefs.current[globalIndex];
if (iframe) injectThemeIntoIframe(iframe);
}} />
</div>
</div>
))
}
</div>
</section>
))
}
</div>
);
}

export {
StoryGrid,
};


Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.grid__section__items {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
}
161 changes: 161 additions & 0 deletions packages/storybook/src/components/themeGenerator/ThemeGenerator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { Splitter } from '@ark-ui/react/splitter';
import React, { type JSX, useEffect, useState } from 'react';
import classNames from 'classnames';
import { Button, BUTTON_COLOR, BUTTON_VARIANT, Icon, ICON_NAME, Switch, type SwitchValueChangeDetail, SwitchItem } from '@ovhcloud/ods-react';
import { ORIENTATION, OrientationSwitch } from '../sandbox/actions/OrientationSwitch';
import styles from './themeGenerator.module.css';
import { ThemeGeneratorTreeView } from './ThemeGeneratorTreeView/ThemeGeneratorTreeView';
import { ThemeGeneratorPreview } from './themeGeneratorPreview/ThemeGeneratorPreview';
import { ThemeGeneratorSwitchThemeModal } from './themeGeneratorSwitchThemeModal/ThemeGeneratorSwitchThemeModal';
import { ThemeGeneratorJSONModal } from './themeGeneratorJSONModal/ThemeGeneratorJSONModal';
import defaultTokens from '@ovhcloud/ods-themes/default/tokens';
import developerTokens from '@ovhcloud/ods-themes/developer/tokens';

const ThemeGenerator = (): JSX.Element => {
const [isFullscreen, setIsFullscreen] = useState(false);
const [orientation, setOrientation] = useState(ORIENTATION.horizontal);
const [selectedTheme, setSelectedTheme] = useState('default');
const [editedVariables, setEditedVariables] = useState<Record<string, string>>({});
const [isCustomTheme, setIsCustomTheme] = useState(false);
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [pendingTheme, setPendingTheme] = useState<string | null>(null);
const [isJsonOpen, setIsJsonOpen] = useState(false);

useEffect(() => {
if (selectedTheme === 'custom') {
setIsCustomTheme(true);
return;
}

const themeTokens = selectedTheme === 'developer' ? developerTokens : defaultTokens;
setEditedVariables(themeTokens.root);
setIsCustomTheme(false);
}, [selectedTheme]);


function onToggleFullscreen() {
setIsFullscreen((v) => !v);
}

function onVariableChange(name: string, value: string) {
setEditedVariables((prev) => ({
...prev,
[name]: value,
}));

if (!isCustomTheme) {
setSelectedTheme('custom');
setIsCustomTheme(true);
}
}

return <div className={ classNames(
styles['theme-generator'],
{ [ styles['theme-generator--fullscreen']]: isFullscreen },
)}>
<div className={ styles['theme-generator__menu'] }>
<div className={styles['theme-generator__menu__left']}>
<Button
variant={ BUTTON_VARIANT.ghost }
onClick={ () => setIsJsonOpen(true) }>
<Icon name={ ICON_NAME.chevronLeftUnderscore } />
JSON
</Button>
<Switch
value={selectedTheme}
onValueChange={(details: SwitchValueChangeDetail) => {
const next = details.value;
const isLeavingCustom = isCustomTheme && next !== 'custom';

if (isLeavingCustom) {
setPendingTheme(next);
setIsConfirmOpen(true);
return;
}

setSelectedTheme(next);
}}
>
<SwitchItem value="default">
Default
</SwitchItem>
<SwitchItem value="developer">
Developer
</SwitchItem>
<SwitchItem value="custom">
Custom
</SwitchItem>
</Switch>
</div>
<div className={styles['theme-generator__menu__right']}>
<OrientationSwitch
onChange={ (value) => setOrientation(value) }
orientation={ orientation } />

<Button
onClick={ onToggleFullscreen }
variant={ BUTTON_VARIANT.ghost }>
<Icon name={ isFullscreen ? ICON_NAME.shrink : ICON_NAME.resize } />
</Button>
</div>
</div>
<Splitter.Root
className={ styles['theme-generator__container'] }
orientation={ orientation }
panels={ [{ id: 'tree-view', minSize: 10 }, { id: 'preview', minSize: 10 }] }>
<Splitter.Panel id="tree-view">
<ThemeGeneratorTreeView
variables={editedVariables}
onVariableChange={onVariableChange} />
</Splitter.Panel>

<Splitter.ResizeTrigger
asChild
aria-label="Resize"
id="tree-view:preview">
<Button
className={ classNames(
styles['theme-generator__container__resize'],
{ [styles['theme-generator__container__resize--horizontal']]: orientation === ORIENTATION.horizontal },
{ [styles['theme-generator__container__resize--vertical']]: orientation === ORIENTATION.vertical },
)}
color={ BUTTON_COLOR.neutral } />
</Splitter.ResizeTrigger>

<Splitter.Panel id="preview">
<div className={ styles['theme-generator__container__preview'] }>
<ThemeGeneratorPreview themeVariables={editedVariables} />
</div>
</Splitter.Panel>
</Splitter.Root>

<ThemeGeneratorSwitchThemeModal
open={ isConfirmOpen }
targetTheme={ pendingTheme }
onConfirm={() => {
if (pendingTheme) {
setSelectedTheme(pendingTheme);
}
setPendingTheme(null);
setIsConfirmOpen(false);
}}
onCancel={() => {
setPendingTheme(null);
setIsConfirmOpen(false);
}}
/>

<ThemeGeneratorJSONModal
open={ isJsonOpen }
variables={ editedVariables }
onClose={ () => setIsJsonOpen(false) }
onReplace={(next) => {
setEditedVariables(next);
setSelectedTheme('custom');
setIsCustomTheme(true);
}}
/>
</div>
}

export { ThemeGenerator };
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Spinner, Text, TreeView, TreeViewNode, TreeViewNodes, SPINNER_SIZE, TEXT_PRESET } from '@ovhcloud/ods-react';
import React, { useMemo } from 'react';
import { categorizeCssVariables } from '../themeVariableUtils';
import styles from './themeGeneratorTreeView.module.css';
interface TreeItem {
id: string;
name: string;
value?: string;
children?: TreeItem[];
}

interface ThemeGeneratorTreeViewProps {
variables: Record<string, string>;
onVariableChange: (name: string, value: string) => void;
}

const groupColorsByPrefix = (colors: Array<{ name: string; value: string }>): TreeItem[] => {
const groups: Record<string, TreeItem> = {};

colors.forEach((color) => {
const match = color.name.match(/--ods-color-([^-]+)/);
const groupName = match ? match[1] : 'other';

if (!groups[groupName]) {
groups[groupName] = {
id: `group-${groupName}`,
name: groupName.charAt(0).toUpperCase() + groupName.slice(1),
children: [],
};
}

groups[groupName].children!.push({
id: color.name,
name: color.name,
value: color.value,
});
});

return Object.values(groups);
};

const ThemeGeneratorTreeView = ({ variables, onVariableChange }: ThemeGeneratorTreeViewProps) => {
const items: TreeItem[] = useMemo(() => {
const categorized = categorizeCssVariables(variables);

return groupColorsByPrefix(categorized.colors);
}, [variables]);

if (Object.keys(variables).length === 0) {
return <div className={styles['theme-generator-tree-view']}>
<Spinner size={SPINNER_SIZE.xs} />
<Text preset={TEXT_PRESET.caption}>
Loading theme...
</Text>
</div>;
}

return (
<TreeView
className={styles['theme-generator-tree-view']}
items={items}
>
<TreeViewNodes>
{items.map((item) => (
<TreeViewNode key={item.id} item={item}>
{({ item, isBranch }: { item: TreeItem; isBranch: boolean }) => (
<div className={styles['theme-generator-tree-view__item']}>
<Text className={styles['theme-generator-tree-view__item__name']}>
{item.name}
</Text>
{!isBranch && item.value && (
<input
className={styles['theme-generator-tree-view__item__color-input']}
type="color"
onClick={(e) => {
e.stopPropagation();
}}
onChange={(e) => {
onVariableChange(item.name, e.target.value);
}}
value={item.value}
/>
)}
</div>
)}
</TreeViewNode>
))}
</TreeViewNodes>
</TreeView>
);
};

export { ThemeGeneratorTreeView };
Loading