From 53f7683193646605cdd8dc3447e75a0fb867aee2 Mon Sep 17 00:00:00 2001 From: GitFred Date: Fri, 17 Oct 2025 17:23:23 +0200 Subject: [PATCH] Add ui_skin data-theme feature --- .storybook/data-themes.ts | 49 ++++++++++ .storybook/preview.ts | 12 +++ README.md | 74 +++++++++++++++ components/card/card.css | 17 +++- sdc-addon.ui_skins.themes.yml | 20 ++++ src/dataThemesGenerator.ts | 142 +++++++++++++++++++++++++++++ src/preset.ts | 2 + src/vite-plugin-theme-generator.ts | 68 ++++++++++++++ 8 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 .storybook/data-themes.ts create mode 100644 sdc-addon.ui_skins.themes.yml create mode 100644 src/dataThemesGenerator.ts create mode 100644 src/vite-plugin-theme-generator.ts diff --git a/.storybook/data-themes.ts b/.storybook/data-themes.ts new file mode 100644 index 0000000..85e3d5a --- /dev/null +++ b/.storybook/data-themes.ts @@ -0,0 +1,49 @@ +// Auto-generated theme configuration from *.ui_skins.themes.yml files +// This file is automatically generated - do not edit manually + +// Theme target mapping +const themeTargets = { + 'cyberpunk': 'body', + 'forest': 'html', + 'garden': 'html' +}; + +// Add a decorator to set data-theme on the correct target element +export const themeDecorators = [ + (storyFn, context) => { + // Wait for the target element to be available + setTimeout(() => { + const selectedTheme = context.globals.theme || 'cyberpunk'; + const targetSelector = themeTargets[selectedTheme] || 'body'; + + // Remove data-theme from both html and body to clean previous themes + const html = document.querySelector('html'); + const body = document.querySelector('body'); + if (html) html.removeAttribute('data-theme'); + if (body) body.removeAttribute('data-theme'); + + // Apply the new theme to the correct target + const target = document.querySelector(targetSelector); + if (target) { + target.setAttribute('data-theme', selectedTheme); + } + }, 0); + return storyFn(); + }, +]; + +export const themeGlobalTypes = { + theme: { + name: 'Theme', + description: 'Global theme for components', + defaultValue: 'cyberpunk', + toolbar: { + icon: 'paintbrush', + items: [ + { value: 'cyberpunk', title: 'Cyberpunk' }, + { value: 'forest', title: 'Forest' }, + { value: 'garden', title: 'Garden' } + ], + }, + }, +}; diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 1c57f73..bb07baf 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,5 +1,17 @@ import type { Preview } from '@storybook/html' +import { themeDecorators, themeGlobalTypes } from './data-themes' + +export const decorators = [ + ...themeDecorators, // Theme decorators + // Your other decorators +] + +export const globalTypes = { + ...themeGlobalTypes, // Theme global types + // Your other global types +} + const preview: Preview = { parameters: { controls: { diff --git a/README.md b/README.md index e1cc546..2aca907 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This addon streamlines the integration of Drupal Single Directory Components (SD - [Features of the Addon](#features-of-the-addon) - [Quickstart Guide](#quickstart-guide) - [Configuration](#configuration) +- [Automatic Theme Generation](#automatic-theme-generation) - [Creating Experimental Stories](#creating-experimental-stories) - [Support for Single Story Files (\*.story.yml)](#support-for-single-story-files-storyyml) - [Namespaces](#namespaces) @@ -645,6 +646,79 @@ Addon supports UI Patterns `library_wrapper` [for wrapping stories](https://proj - Custom Twig filters and functions are not supported ([UI Patterns TwigExtension](https://git.drupalcode.org/project/ui_patterns/-/blob/8.x-1.x/src/Template/TwigExtension.php)). +## UI Skins + +The addon supports ui_skins yml definition and creates Storybook theme decorators and global types from `*.ui_skins.themes.yml` files. This allows you to easily switch between different data-themes in Storybook. + +### How it works + +The addon automatically scans for `*.ui_skins.themes.yml` files and generates a `data-themes.ts` file in your `.storybook` directory. This file contains theme decorators and global types that can be imported into your `preview.ts`. + +### Theme Configuration + +Create a `*.ui_skins.themes.yml` file in your project root with the following structure: + +```yaml +# Theme configuration example +cyberpunk: + label: "Cyberpunk" + label_context: "color" + key: "data-theme" + target: body + +forest: + label: "Forest" + label_context: "color" + key: "data-theme" + target: html + +garden: + label: "Garden" + label_context: "color" + key: "data-theme" + target: html +``` + +### Configuration Options + +See UI skins for options + +### Using Themes + +After the 'data-themes.ts' is generated, you can import them into your `.storybook/preview.ts`: + +```typescript +import type { Preview } from '@storybook/html' +import { themeDecorators, themeGlobalTypes } from './data-themes' + +export const decorators = [ + ...themeDecorators, // Theme decorators + // Your other decorators +] + +export const globalTypes = { + ...themeGlobalTypes, // Theme global types + // Your other global types +} + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + }, +} + +export default preview +``` + +### CSS Integration + +The data-themes work with CSS custom properties. You can define theme-specific styles in your component CSS. See card component for exemple + ## Why Choose SDC Storybook Over Alternatives? While solutions like [SDC Styleguide](https://www.drupal.org/project/sdc_styleguide) and [Drupal Storybook](https://www.drupal.org/project/storybook) are valuable, the SDC Storybook addon offers distinct advantages: diff --git a/components/card/card.css b/components/card/card.css index b7ee18f..38b7072 100644 --- a/components/card/card.css +++ b/components/card/card.css @@ -1,7 +1,20 @@ +[data-theme="cyberpunk"] { + --color-primary: rgb(0, 255, 255); + --color-primary-content: rgb(0, 0, 0); +} +[data-theme="forest"] { + --color-primary: rgb(2, 39, 2); + --color-primary-content: rgb(255, 255, 255); +} +[data-theme="garden"] { + --color-primary: rgb(50, 205, 50); + --color-primary-content: rgb(0, 0, 0); +} .umami-card { padding: 1rem; - border: 1px solid lightgray; - background-color: #fff; + border: 1px solid var(--color-primary-content, lightgray); + background-color: var(--color-primary, #fff ); + color: var(--color-primary-content); border-radius: 0.5rem; } diff --git a/sdc-addon.ui_skins.themes.yml b/sdc-addon.ui_skins.themes.yml new file mode 100644 index 0000000..f4e34d8 --- /dev/null +++ b/sdc-addon.ui_skins.themes.yml @@ -0,0 +1,20 @@ +# No value because same as plugin ID. +cyberpunk: + label: "Cyberpunk" + label_context: "color" + key: "data-theme" + target: body + +# No value because same as plugin ID. +forest: + label: "Forest" + label_context: "color" + key: "data-theme" + target: html + +# No value because same as plugin ID. +garden: + label: "Garden" + label_context: "color" + key: "data-theme" + target: html \ No newline at end of file diff --git a/src/dataThemesGenerator.ts b/src/dataThemesGenerator.ts new file mode 100644 index 0000000..f49ac79 --- /dev/null +++ b/src/dataThemesGenerator.ts @@ -0,0 +1,142 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { glob } from 'glob' +import { parse } from 'yaml' +import { join } from 'node:path' + +export interface ThemeConfig { + key: string + label: string + value: string + target: string +} + +/** + * Generate Storybook data-themes.ts configuration from *.ui_skins.themes.yml files + */ +export async function generateThemes(): Promise { + console.log('🎨 Generating theme configuration from *.ui_skins.themes.yml files...') + + try { + // Find all theme files + const themeFiles = await glob('**/*.ui_skins.themes.yml', { cwd: process.cwd() }) + + if (themeFiles.length === 0) { + console.log('⚠️ No *.ui_skins.themes.yml files found') + return + } + + console.log(`📁 Found ${themeFiles.length} theme file(s):`, themeFiles) + + const themes: ThemeConfig[] = [] + + // Parse each theme file + for (const themeFile of themeFiles) { + try { + const content = await readFile(themeFile, 'utf8') + const themeData = parse(content) + + // Extract theme information + for (const [themeKey, themeConfig] of Object.entries(themeData)) { + if (typeof themeConfig === 'object' && themeConfig !== null) { + themes.push({ + key: themeKey, + label: (themeConfig as any).label || themeKey, + value: themeKey, + target: (themeConfig as any).target || 'body' + }) + } + } + + console.log(`✅ Parsed theme file: ${themeFile}`) + } catch (error) { + console.error(`❌ Error parsing ${themeFile}:`, (error as Error).message) + } + } + + if (themes.length === 0) { + console.log('⚠️ No themes found in the files') + return + } + + console.log(`🎯 Found ${themes.length} theme(s):`, themes.map(t => t.key)) + + // Generate the themes configuration file + const themesConfigContent = generateThemesConfig(themes) + const themesConfigPath = join(process.cwd(), '.storybook', 'data-themes.ts') + await writeFile(themesConfigPath, themesConfigContent, 'utf8') + + console.log(`✅ Generated data-themes.ts with ${themes.length} theme(s)`) + console.log(`📝 Updated: ${themesConfigPath}`) + console.log(`💡 To use themes, add to your preview.ts:`) + console.log(` import { themeDecorators, themeGlobalTypes } from './data-themes'`) + console.log(` Then add ...themeDecorators to decorators and ...themeGlobalTypes to globalTypes`) + + } catch (error) { + console.error('❌ Error generating themes:', error) + throw error + } +} + +/** + * Generate the themes configuration file + */ +export function generateThemesConfig(themes: ThemeConfig[]): string { + const themeItems = themes.map(theme => + ` { value: '${theme.value}', title: '${theme.label}' }` + ).join(',\n') + + const defaultTheme = themes.length > 0 ? themes[0].value : 'default' + const defaultTarget = themes.length > 0 ? themes[0].target : 'body' + + // Create theme target mapping + const themeTargets = themes.map(theme => + ` '${theme.value}': '${theme.target}'` + ).join(',\n') + + return `// Auto-generated theme configuration from *.ui_skins.themes.yml files +// This file is automatically generated - do not edit manually + +// Theme target mapping +const themeTargets = { +${themeTargets} +}; + +// Add a decorator to set data-theme on the correct target element +export const themeDecorators = [ + (storyFn, context) => { + // Wait for the target element to be available + setTimeout(() => { + const selectedTheme = context.globals.theme || '${defaultTheme}'; + const targetSelector = themeTargets[selectedTheme] || '${defaultTarget}'; + + // Remove data-theme from both html and body to clean previous themes + const html = document.querySelector('html'); + const body = document.querySelector('body'); + if (html) html.removeAttribute('data-theme'); + if (body) body.removeAttribute('data-theme'); + + // Apply the new theme to the correct target + const target = document.querySelector(targetSelector); + if (target) { + target.setAttribute('data-theme', selectedTheme); + } + }, 0); + return storyFn(); + }, +]; + +export const themeGlobalTypes = { + theme: { + name: 'Theme', + description: 'Global theme for components', + defaultValue: '${defaultTheme}', + toolbar: { + icon: 'paintbrush', + items: [ +${themeItems} + ], + }, + }, +}; +` +} diff --git a/src/preset.ts b/src/preset.ts index 4691a0c..487f891 100644 --- a/src/preset.ts +++ b/src/preset.ts @@ -1,6 +1,7 @@ import YamlStoriesPlugin, { yamlStoriesIndexer, } from './vite-plugin-storybook-yaml-stories.ts' +import vitePluginThemeGenerator from './vite-plugin-theme-generator.ts' import { mergeConfig } from 'vite' import type { UserConfig } from 'vite' import type { Indexer } from 'storybook/internal/types' @@ -52,6 +53,7 @@ export async function viteFinal(config: UserConfig, options: SDCAddonOptions) { include: ['buffer', 'stream', 'path'], }), ...(twigPlugin ? [twigPlugin] : []), + vitePluginThemeGenerator(), YamlStoriesPlugin({ ...options, globalDefs, namespaces }), ], optimizeDeps: { diff --git a/src/vite-plugin-theme-generator.ts b/src/vite-plugin-theme-generator.ts new file mode 100644 index 0000000..42e39e9 --- /dev/null +++ b/src/vite-plugin-theme-generator.ts @@ -0,0 +1,68 @@ +import type { Plugin } from 'vite' +import { generateThemes } from './dataThemesGenerator.ts' +import { logger } from './logger.ts' + +export interface ThemeGeneratorOptions { + /** + * Whether to generate themes on build start + * @default true + */ + generateOnStart?: boolean + + /** + * Whether to watch theme files for changes + * @default true + */ + watch?: boolean +} + +/** + * Vite plugin for automatic theme generation from *.ui_skins.themes.yml files + */ +export default function vitePluginThemeGenerator(options: ThemeGeneratorOptions = {}): Plugin { + const { + generateOnStart = true, + watch = true + } = options + + let hasGenerated = false + + return { + name: 'vite-plugin-theme-generator', + + async buildStart() { + if (generateOnStart && !hasGenerated) { + try { + await generateThemes() + hasGenerated = true + } catch (error) { + logger.warn('Failed to generate themes on build start:', error) + } + } + }, + + async load(id: string) { + // Generate themes when loading any component.yml file + if (id.endsWith('component.yml') && !hasGenerated) { + try { + await generateThemes() + hasGenerated = true + } catch (error) { + logger.warn('Failed to generate themes:', error) + } + } + }, + + async handleHotUpdate({ file }) { + // Regenerate themes when theme files change + if (watch && file.endsWith('.ui_skins.themes.yml')) { + try { + await generateThemes() + logger.info('🎨 Themes regenerated due to file change:', file) + } catch (error) { + logger.warn('Failed to regenerate themes:', error) + } + } + } + } +}