diff --git a/.changeset/color-palette-handling.md b/.changeset/color-palette-handling.md new file mode 100644 index 000000000..20095d4a3 --- /dev/null +++ b/.changeset/color-palette-handling.md @@ -0,0 +1,38 @@ +--- +'@pandacss/types': minor +'@pandacss/token-dictionary': minor +'@pandacss/core': minor +'@pandacss/config': minor +'@pandacss/generator': minor +--- + +Add support for controlling the color palette generation via `theme.colorPalette` property. + +```ts +// Disable color palette generation completely +export default defineConfig({ + theme: { + colorPalette: { + enabled: false, + }, + }, +}) + +// Include only specific colors +export default defineConfig({ + theme: { + colorPalette: { + include: ['gray', 'blue', 'red'], + }, + }, +}) + +// Exclude specific colors +export default defineConfig({ + theme: { + colorPalette: { + exclude: ['yellow', 'orange'], + }, + }, +}) +``` diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts index 429c954d4..36dc6fb60 100644 --- a/packages/core/src/context.ts +++ b/packages/core/src/context.ts @@ -258,6 +258,7 @@ export class Context { themes: themeVariants, prefix: this.prefix.tokens, hash: this.hash.tokens, + colorPalette: theme.colorPalette, }) } diff --git a/packages/token-dictionary/__tests__/color-palette.test.ts b/packages/token-dictionary/__tests__/color-palette.test.ts index e8c08d585..83213f70b 100644 --- a/packages/token-dictionary/__tests__/color-palette.test.ts +++ b/packages/token-dictionary/__tests__/color-palette.test.ts @@ -3,6 +3,15 @@ import { addVirtualPalette } from '../src/middleware' import { transforms } from '../src/transform' import { TokenDictionary } from '../src/dictionary' +const createDictionary = (options: any = {}) => { + const dictionary = new TokenDictionary(options) + return dictionary + .registerTokens() + .registerTransform(...transforms) + .registerMiddleware(addVirtualPalette) + .build() +} + const dasherize = (token: string) => token .toString() @@ -1634,3 +1643,161 @@ test('should generate virtual palette with DEFAULT value', () => { } `) }) + +test('should disable color palette when enabled is false', () => { + const dictionary = createDictionary({ + tokens: { + colors: { + red: { + 500: { value: '#red500' }, + 700: { value: '#red700' }, + }, + blue: { + 500: { value: '#blue500' }, + 700: { value: '#blue700' }, + }, + }, + }, + colorPalette: { + enabled: false, + }, + }) + + const getVar = dictionary.view.get + expect(getVar('colors.colorPalette.500')).toBeUndefined() + expect(getVar('colors.colorPalette.700')).toBeUndefined() + + expect(Array.from(dictionary.view.colorPalettes.keys())).toHaveLength(0) +}) + +test('should include only specified colors in color palette', () => { + const dictionary = createDictionary({ + tokens: { + colors: { + red: { + 500: { value: '#red500' }, + 700: { value: '#red700' }, + }, + blue: { + 500: { value: '#blue500' }, + 700: { value: '#blue700' }, + }, + green: { + 500: { value: '#green500' }, + 700: { value: '#green700' }, + }, + }, + }, + colorPalette: { + include: ['red', 'blue'], + }, + }) + + const getVar = dictionary.view.get + expect(getVar('colors.colorPalette.500')).toBe('var(--colors-color-palette-500)') + expect(getVar('colors.colorPalette.700')).toBe('var(--colors-color-palette-700)') + + expect(Array.from(dictionary.view.colorPalettes.keys())).toMatchInlineSnapshot(` + [ + "red", + "blue", + ] + `) +}) + +test('should exclude specified colors from color palette', () => { + const dictionary = createDictionary({ + tokens: { + colors: { + red: { + 500: { value: '#red500' }, + 700: { value: '#red700' }, + }, + blue: { + 500: { value: '#blue500' }, + 700: { value: '#blue700' }, + }, + green: { + 500: { value: '#green500' }, + 700: { value: '#green700' }, + }, + }, + }, + colorPalette: { + exclude: ['red'], + }, + }) + + const getVar = dictionary.view.get + expect(getVar('colors.colorPalette.500')).toBe('var(--colors-color-palette-500)') + expect(getVar('colors.colorPalette.700')).toBe('var(--colors-color-palette-700)') + + expect(Array.from(dictionary.view.colorPalettes.keys())).toMatchInlineSnapshot(` + [ + "blue", + "green", + ] + `) +}) + +test('should handle semantic tokens with colorPalette configuration', () => { + const dictionary = createDictionary({ + semanticTokens: { + colors: { + primary: { + value: '{colors.blue.500}', + }, + secondary: { + value: '{colors.red.500}', + }, + accent: { + value: '{colors.green.500}', + }, + }, + }, + tokens: { + colors: { + blue: { + 500: { value: '#blue500' }, + }, + red: { + 500: { value: '#red500' }, + }, + green: { + 500: { value: '#green500' }, + }, + }, + }, + colorPalette: { + include: ['primary'], + }, + }) + + expect(Array.from(dictionary.view.colorPalettes.keys())).toMatchInlineSnapshot(` + [ + "primary", + ] + `) +}) + +test('should enable color palette by default when no configuration is provided', () => { + const dictionary = createDictionary({ + tokens: { + colors: { + red: { + 500: { value: '#red500' }, + }, + }, + }, + // No colorPalette config provided - should default to enabled: true + }) + + const getVar = dictionary.view.get + expect(getVar('colors.colorPalette.500')).toBe('var(--colors-color-palette-500)') + + expect(Array.from(dictionary.view.colorPalettes.keys())).toMatchInlineSnapshot(` + [ + "red", + ] + `) +}) diff --git a/packages/token-dictionary/package.json b/packages/token-dictionary/package.json index a04a9d49a..40c594455 100644 --- a/packages/token-dictionary/package.json +++ b/packages/token-dictionary/package.json @@ -41,6 +41,10 @@ "@pandacss/logger": "workspace:^", "@pandacss/shared": "workspace:*", "@pandacss/types": "workspace:*", + "picomatch": "^4.0.0", "ts-pattern": "5.8.0" + }, + "devDependencies": { + "@types/picomatch": "4.0.2" } } diff --git a/packages/token-dictionary/src/dictionary.ts b/packages/token-dictionary/src/dictionary.ts index c4396d676..f0bcdbf9b 100644 --- a/packages/token-dictionary/src/dictionary.ts +++ b/packages/token-dictionary/src/dictionary.ts @@ -11,7 +11,14 @@ import { type CssVar, type CssVarOptions, } from '@pandacss/shared' -import type { Recursive, SemanticTokens, ThemeVariantsMap, TokenCategory, Tokens } from '@pandacss/types' +import type { + Recursive, + SemanticTokens, + ThemeVariantsMap, + TokenCategory, + Tokens, + ColorPaletteOptions, +} from '@pandacss/types' import { isMatching, match } from 'ts-pattern' import { isCompositeTokenValue } from './is-composite' import { middlewares } from './middleware' @@ -37,6 +44,7 @@ export interface TokenDictionaryOptions { themes?: ThemeVariantsMap | undefined prefix?: string hash?: boolean + colorPalette?: ColorPaletteOptions } export interface TokenMiddleware { @@ -81,6 +89,10 @@ export class TokenDictionary { return this.options.hash } + get colorPalette() { + return this.options.colorPalette + } + getByName = (path: string) => { return this.byName.get(path) } diff --git a/packages/token-dictionary/src/middleware.ts b/packages/token-dictionary/src/middleware.ts index 5ed9ca05d..f767feec4 100644 --- a/packages/token-dictionary/src/middleware.ts +++ b/packages/token-dictionary/src/middleware.ts @@ -64,6 +64,12 @@ export const addPixelUnit: TokenMiddleware = { export const addVirtualPalette: TokenMiddleware = { enforce: 'post', transform(dictionary: TokenDictionary) { + const colorPaletteConfig = dictionary.colorPalette + const enabled = colorPaletteConfig?.enabled ?? true + + // If disabled, skip generating color palettes + if (!enabled) return + const tokens = dictionary.filter({ extensions: { category: 'colors' } }) const keys = new Map() diff --git a/packages/token-dictionary/src/transform.ts b/packages/token-dictionary/src/transform.ts index 98d0db7a8..9b7c7511d 100644 --- a/packages/token-dictionary/src/transform.ts +++ b/packages/token-dictionary/src/transform.ts @@ -1,5 +1,6 @@ import { isCssUnit, isString, PandaError } from '@pandacss/shared' import type { TokenDataTypes } from '@pandacss/types' +import picomatch from 'picomatch' import { P, match } from 'ts-pattern' import type { TokenTransformer } from './dictionary' import { isCompositeBorder, isCompositeGradient, isCompositeShadow } from './is-composite' @@ -229,53 +230,65 @@ export const addColorPalette: TokenTransformer = { return token.extensions.category === 'colors' && !token.extensions.isVirtual }, transform(token, dict) { - let tokenPathClone = [...token.path] - tokenPathClone.pop() - tokenPathClone.shift() - - if (tokenPathClone.length === 0) { - const newPath = [...token.path] - newPath.shift() - tokenPathClone = newPath + // Check colorPalette configuration + const colorPaletteConfig = dict.colorPalette + const enabled = colorPaletteConfig?.enabled ?? true + const include = colorPaletteConfig?.include + const exclude = colorPaletteConfig?.exclude + + // If disabled, don't add colorPalette extensions + if (!enabled) return {} + + // Extract color path (remove 'colors' prefix and last segment) + // ['colors', 'blue', '500'] -> ['blue'] + // ['colors', 'button', 'light', 'accent', 'secondary'] -> ['button', 'light', 'accent'] + // ['colors', 'primary'] -> ['primary'] (handle flat tokens) + let colorPath = token.path.slice(1, -1) + + // If no nested segments, use the path without the 'colors' prefix + if (colorPath.length === 0) { + colorPath = token.path.slice(1) + if (colorPath.length === 0) { + return {} + } } - if (tokenPathClone.length === 0) { - return {} + // Convert path segments to dot-notation string for pattern matching + const colorPathString = colorPath.join('.') + + // Check include/exclude filters using picomatch (supports glob patterns) + // Exclude takes precedence over include + if (exclude?.length) { + const excludeMatchers = exclude.map((pattern) => picomatch(pattern)) + if (excludeMatchers.some((matcher) => matcher(colorPathString))) { + return {} + } + } + + if (include?.length) { + const includeMatchers = include.map((pattern) => picomatch(pattern)) + if (!includeMatchers.some((matcher) => matcher(colorPathString))) { + return {} + } } /** - * If this is the nested color palette: - * ```json - * { - * "colors": { - * "button": { - * "light": { - * "accent": { - * "secondary": { - * value: 'blue', - * }, - * }, - * }, - * }, - * }, - * }, - * ``` + * Generate all possible color palette roots from the color path. + * + * For ['button', 'light', 'accent']: + * - ['button'] + * - ['button', 'light'] + * - ['button', 'light', 'accent'] * - * The `colorPaletteRoots` will be `['button', 'button.light', 'button.light.accent']`. - * It holds all the possible values you can pass to the css `colorPalette` property. - * It's used by the `addVirtualPalette` middleware to build the virtual `colorPalette` token for each color pattern root. + * These represent all possible values you can pass to the css `colorPalette` property. */ - const colorPaletteRoots = tokenPathClone.reduce( - (acc, _, i, arr) => { - const next = arr.slice(0, i + 1) - acc.push(next) - return acc - }, - [] as Array, - ) - - const colorPaletteRoot = tokenPathClone[0] - const colorPalette = dict.formatTokenName(tokenPathClone) + const colorPaletteRoots: string[][] = [] + for (let i = 0; i < colorPath.length; i++) { + colorPaletteRoots.push(colorPath.slice(0, i + 1)) + } + + const colorPaletteRoot = colorPath[0] + const colorPalette = dict.formatTokenName(colorPath) /** * If this is the nested color palette: @@ -329,16 +342,15 @@ export const addColorPalette: TokenTransformer = { * })} * /> */ - const colorPaletteTokenKeys = token.path - // Remove everything before colorPalette root and the root itself - .slice(token.path.indexOf(colorPaletteRoot) + 1) - .reduce( - (acc, _, i, arr) => { - acc.push(arr.slice(i)) - return acc - }, - [] as Array, - ) + // Remove everything before colorPalette root and the root itself + const startIndex = token.path.indexOf(colorPaletteRoot) + 1 + const remainingPath = token.path.slice(startIndex) + const colorPaletteTokenKeys: string[][] = [] + + // Generate all suffixes of the remaining path + for (let i = 0; i < remainingPath.length; i++) { + colorPaletteTokenKeys.push(remainingPath.slice(i)) + } // https://github.com/chakra-ui/panda/issues/1421 if (colorPaletteTokenKeys.length === 0) { diff --git a/packages/types/src/theme.ts b/packages/types/src/theme.ts index 3b8375bf3..97e1ceef7 100644 --- a/packages/types/src/theme.ts +++ b/packages/types/src/theme.ts @@ -3,6 +3,24 @@ import type { RecipeConfig, SlotRecipeConfig } from './recipe' import type { CssKeyframes } from './system-types' import type { SemanticTokens, Tokens } from './tokens' +export interface ColorPaletteOptions { + /** + * Whether to enable color palette generation. + * @default true + */ + enabled?: boolean + /** + * List of color names to include in color palette generation. + * When specified, only these colors will be used for color palettes. + */ + include?: string[] + /** + * List of color names to exclude from color palette generation. + * When specified, these colors will not be used for color palettes. + */ + exclude?: string[] +} + export interface Theme { /** * The breakpoints for your project. @@ -49,6 +67,10 @@ export interface Theme { * The predefined container sizes for your project. */ containerSizes?: Record + /** + * The color palette configuration for your project. + */ + colorPalette?: ColorPaletteOptions } interface PartialTheme extends Omit { @@ -61,6 +83,10 @@ interface PartialTheme extends Omit { * Multi-variant style definitions for component slots. */ slotRecipes?: Record> + /** + * The color palette configuration for your project. + */ + colorPalette?: Partial } export interface ExtendableTheme extends Theme { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a4fa4f51..f6d902bc9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -733,9 +733,16 @@ importers: '@pandacss/types': specifier: workspace:* version: link:../types + picomatch: + specifier: ^4.0.0 + version: 4.0.3 ts-pattern: specifier: 5.8.0 version: 5.8.0 + devDependencies: + '@types/picomatch': + specifier: 4.0.2 + version: 4.0.2 packages/types: devDependencies: diff --git a/website/content/docs/concepts/virtual-color.mdx b/website/content/docs/concepts/virtual-color.mdx index f150d1979..001c85911 100644 --- a/website/content/docs/concepts/virtual-color.mdx +++ b/website/content/docs/concepts/virtual-color.mdx @@ -206,3 +206,81 @@ function ButtonShowcase() { ) } ``` + +## Configuration + +By default, color palette generation is enabled and includes all colors defined in your theme. + +You can control which colors are used to generate color palettes by configuring the `colorPalette` property in your theme. + +### Disable Color Palette + +To completely disable color palette generation, set `enabled` to `false`: + +```ts filename="panda.config.ts" +import { defineConfig } from '@pandacss/dev' + +export default defineConfig({ + theme: { + colorPalette: { + enabled: false + } + } +}) +``` + +### Include Specific Colors + +To generate color palettes for only specific colors, use the `include` option: + +```ts filename="panda.config.ts" +import { defineConfig } from '@pandacss/dev' + +export default defineConfig({ + theme: { + colorPalette: { + include: ['gray', 'blue', 'red'] + } + } +}) +``` + +This will only generate color palettes for `gray`, `blue`, and `red` colors, even if you have other colors defined in your theme. + +### Exclude Specific Colors + +To exclude certain colors from color palette generation, use the `exclude` option: + +```ts filename="panda.config.ts" +import { defineConfig } from '@pandacss/dev' + +export default defineConfig({ + theme: { + colorPalette: { + exclude: ['yellow', 'orange'] + } + } +}) +``` + +This will generate color palettes for all colors except `yellow` and `orange`. + +### Combination of Options + +You can combine the `enabled`, `include`, and `exclude` options as needed: + +```ts filename="panda.config.ts" +import { defineConfig } from '@pandacss/dev' + +export default defineConfig({ + theme: { + colorPalette: { + enabled: true, + include: ['gray', 'blue', 'red', 'green'], + exclude: ['red'] // This will override the include for 'red' + } + } +}) +``` + +In this example, color palettes will be generated for `gray`, `blue`, and `green`, but not for `red` (since it's excluded).