Skip to content

Commit c45dc49

Browse files
authored
Merge pull request #415 (css)
feat: refactor selector handling in `@poupe/theme-builder` and `@poupe/tailwindcss`
2 parents 3bcd04e + cd254ca commit c45dc49

File tree

7 files changed

+121
-91
lines changed

7 files changed

+121
-91
lines changed

packages/@poupe-tailwindcss/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@poupe/tailwindcss",
3-
"version": "0.3.12",
3+
"version": "0.3.13",
44
"type": "module",
55
"description": "TailwindCSS v4 plugin for Poupe UI framework with theme customization support",
66
"author": "Alejandro Mery <amery@apptly.co>",

packages/@poupe-tailwindcss/src/theme/theme.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ export function makeThemeBases(
307307
darkSuffix: theme.options.darkSuffix,
308308
lightSuffix: theme.options.lightSuffix,
309309
stringify: stringify || hslString,
310+
addStarVariantsToDark: false,
310311
},
311312
);
312313

packages/@poupe-tailwindcss/src/theme/variants.ts

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
processCSSSelectors,
23
type CSSRuleObject,
34
} from '@poupe/css';
45

@@ -94,11 +95,8 @@ export function getDarkMode(darkMode: DarkModeStrategy = 'class'): string[] {
9495
const [mode, value] = darkMode;
9596
switch (mode) {
9697
case 'class':
97-
case 'selector': {
98-
const v = makeDarkModeSelector(value);
99-
if (v.length > 0)
100-
return v;
101-
}
98+
case 'selector':
99+
return processCSSSelectors(value) ?? [defaultDarkSelector];
102100
}
103101

104102
// TODO: variant modes
@@ -109,21 +107,4 @@ export function getDarkMode(darkMode: DarkModeStrategy = 'class'): string[] {
109107
}
110108
}
111109

112-
function makeDarkModeSelector(value: string | string[]): string[] {
113-
if (Array.isArray(value)) {
114-
const sanitized: string[] = [];
115-
116-
// remove empty slots
117-
for (const v of value) {
118-
const trimmed = v.trim();
119-
if (trimmed != '') sanitized.push(trimmed);
120-
}
121-
122-
return sanitized.length > 0 ? sanitized : [defaultDarkSelector];
123-
}
124-
125-
const trimmed = value.trim();
126-
return trimmed == '' ? [defaultDarkSelector] : [trimmed];
127-
}
128-
129110
const defaultDarkSelector = '.dark, .dark *';

packages/@poupe-theme-builder/README.md

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,20 @@ const cssTheme = makeCSSTheme({
8585
scheme: 'content',
8686
contrastLevel: 0.5,
8787
prefix: 'md-',
88-
darkMode: '.dark',
88+
darkMode: ['.dark', 'media'], // Multiple selectors with aliases
89+
lightMode: '.light',
90+
})
91+
92+
// Built-in responsive aliases
93+
makeCSSTheme(colors, {
94+
darkMode: ['dark', 'mobile'], // Uses media queries
95+
lightMode: ['light', 'desktop'] // Now supports arrays too
96+
})
97+
98+
// Advanced selector configuration
99+
const advancedTheme = makeCSSTheme(colors, {
100+
darkMode: ['mobile', '.dark-mode'], // Mobile screens + custom class
101+
lightMode: ['desktop', '.light-mode'], // Desktop + custom class
89102
})
90103

91104
// Use generated CSS variables
@@ -254,15 +267,24 @@ cssTheme.styles // CSS rule objects
254267

255268
## Dark Mode
256269

257-
Built-in dark mode support with flexible selectors:
270+
Built-in dark mode support with flexible selectors and aliases:
258271

259272
```typescript
260273
// Class-based dark mode (default)
261274
makeCSSTheme(colors, { darkMode: '.dark' })
262275

263-
// Media query dark mode
276+
// Media query dark mode using built-in alias
264277
makeCSSTheme(colors, { darkMode: 'media' })
265278

279+
// Multiple selectors
280+
makeCSSTheme(colors, { darkMode: ['.dark', '.theme-dark'] })
281+
282+
// Built-in responsive aliases
283+
makeCSSTheme(colors, {
284+
darkMode: ['dark', 'mobile'], // Uses media queries
285+
lightMode: 'light'
286+
})
287+
266288
// Custom selectors
267289
makeCSSTheme(colors, {
268290
darkMode: '[data-theme="dark"]',
@@ -273,6 +295,24 @@ makeCSSTheme(colors, {
273295
makeCSSTheme(colors, { darkMode: false })
274296
```
275297

298+
### Built-in Selector Aliases
299+
300+
The theme builder includes convenient aliases for common media queries:
301+
302+
- `'media'` or `'dark'``'@media (prefers-color-scheme: dark)'`
303+
- `'light'``'@media (prefers-color-scheme: light)'`
304+
- `'mobile'``'@media (max-width: 768px)'`
305+
- `'tablet'``'@media (min-width: 769px) and (max-width: 1024px)'`
306+
- `'desktop'``'@media (min-width: 1025px)'`
307+
308+
```typescript
309+
// Using aliases for responsive theming
310+
const cssTheme = makeCSSTheme(colors, {
311+
darkMode: ['dark', 'tablet'], // Dark mode + tablet screens
312+
lightMode: ['light', 'desktop'], // Light mode + desktop screens
313+
})
314+
```
315+
276316
## Integration with Poupe Ecosystem
277317

278318
- [@poupe/css](../@poupe-css) - CSS utility library

packages/@poupe-theme-builder/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@poupe/theme-builder",
3-
"version": "0.9.2",
3+
"version": "0.9.3",
44
"type": "module",
55
"description": "Design token management and theme generation system for Poupe UI framework",
66
"author": "Alejandro Mery <amery@apptly.co>",

packages/@poupe-theme-builder/src/css/__tests__/css.test.ts

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -60,27 +60,27 @@ describe('defaultCSSThemeOptions', () => {
6060

6161
describe('defaultDarkSelector', () => {
6262
it('should return .dark for true or .dark', () => {
63-
expect(defaultDarkSelector({ darkMode: true })).toBe('.dark, .dark *');
64-
expect(defaultDarkSelector({ darkMode: '.dark' })).toBe('.dark, .dark *');
63+
expect(defaultDarkSelector({ darkMode: true })).toEqual(['.dark, .dark *']);
64+
expect(defaultDarkSelector({ darkMode: '.dark' })).toEqual(['.dark, .dark *']);
6565
});
6666

6767
it('should return media query for false, empty string, or media', () => {
68-
const mediaQuery = '@media not print and (prefers-color-scheme: dark)';
69-
expect(defaultDarkSelector({ darkMode: false })).toBe(mediaQuery);
70-
expect(defaultDarkSelector({ darkMode: '' })).toBe(mediaQuery);
71-
expect(defaultDarkSelector({ darkMode: 'media' })).toBe(mediaQuery);
68+
const mediaQuery = '@media (prefers-color-scheme: dark)';
69+
expect(defaultDarkSelector({ darkMode: false })).toEqual([mediaQuery]);
70+
expect(defaultDarkSelector({ darkMode: '' })).toEqual([mediaQuery]);
71+
expect(defaultDarkSelector({ darkMode: 'media' })).toEqual([mediaQuery]);
7272
});
7373

7474
it('should return custom selector when provided', () => {
7575
const custom = '.custom-dark-mode';
76-
expect(defaultDarkSelector({ darkMode: custom })).toBe(`${custom}, ${custom} *`);
76+
expect(defaultDarkSelector({ darkMode: custom })).toEqual([`${custom}, ${custom} *`]);
7777
});
7878
});
7979

8080
describe('defaultLightSelector', () => {
8181
it('should return .light for true or .light', () => {
82-
expect(defaultLightSelector({ lightMode: true })).toBe('.light, .light *');
83-
expect(defaultLightSelector({ lightMode: '.light' })).toBe('.light, .light *');
82+
expect(defaultLightSelector({ lightMode: true })).toEqual(['.light, .light *']);
83+
expect(defaultLightSelector({ lightMode: '.light' })).toEqual(['.light, .light *']);
8484
});
8585

8686
it('should return undefined for false or empty string', () => {
@@ -90,7 +90,7 @@ describe('defaultLightSelector', () => {
9090

9191
it('should return custom selector when provided', () => {
9292
const custom = '.custom-light-mode';
93-
expect(defaultLightSelector({ lightMode: custom })).toBe(`${custom}, ${custom} *`);
93+
expect(defaultLightSelector({ lightMode: custom })).toEqual([`${custom}, ${custom} *`]);
9494
});
9595
});
9696

@@ -416,20 +416,18 @@ describe('deduplication in generateCSSColorVariables', () => {
416416
});
417417

418418
describe('defaultRootLightSelector', () => {
419-
if (typeof defaultRootLightSelector === 'function') {
420-
it('should return :root when lightMode is false or empty', () => {
421-
expect(defaultRootLightSelector({ lightMode: false })).toBe(':root');
422-
expect(defaultRootLightSelector({ lightMode: '' })).toBe(':root');
423-
});
419+
it('should return :root when lightMode is false or empty', () => {
420+
expect(defaultRootLightSelector({ lightMode: false })).toEqual([':root']);
421+
expect(defaultRootLightSelector({ lightMode: '' })).toEqual([':root']);
422+
});
424423

425-
it('should combine :root with light selector when lightMode is defined', () => {
426-
const selector = defaultRootLightSelector({ lightMode: '.light-theme' });
427-
expect(selector).toBe(':root, .light-theme, .light-theme *');
424+
it('should combine :root with light selector when lightMode is defined', () => {
425+
const selector = defaultRootLightSelector({ lightMode: '.light-theme' });
426+
expect(selector).toEqual([':root, .light-theme, .light-theme *']);
428427

429-
const defaultSelector = defaultRootLightSelector({});
430-
expect(defaultSelector).toBe(':root, .light, .light *');
431-
});
432-
}
428+
const defaultSelector = defaultRootLightSelector({});
429+
expect(defaultSelector).toEqual([':root, .light, .light *']);
430+
});
433431
});
434432

435433
describe('assembleCSSRules', () => {
@@ -448,6 +446,7 @@ describe('assembleCSSRules', () => {
448446
expect(result[0][':root']).toEqual(root);
449447
expect(result[1]).toHaveProperty(':root, .light, .light *');
450448
expect(result[1][':root, .light, .light *']).toEqual(light);
449+
expect(result[1]).toHaveProperty('.dark, .dark *');
451450
expect(result[1]['.dark, .dark *']).toEqual(dark);
452451
});
453452

packages/@poupe-theme-builder/src/css/css.ts

Lines changed: 51 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type CSSRuleObject,
77
unsafeKeys,
88
setDeepRule,
9+
processCSSSelectors,
910
} from '@poupe/css';
1011

1112
import {
@@ -26,7 +27,7 @@ export interface CSSThemeOptions {
2627
/** @defaultValue `'.dark'` */
2728
darkMode: boolean | string | string[]
2829
/** @defaultValue `'.light'` */
29-
lightMode: boolean | string
30+
lightMode: boolean | string | string[]
3031
/** @defaultValue `'md-'` */
3132
prefix: string
3233
/** @defaultValue `'-dark'` */
@@ -35,6 +36,10 @@ export interface CSSThemeOptions {
3536
lightSuffix: string
3637
/** @defaultValue `rgb('{r} {g} {b}')` */
3738
stringify: (c: Hct) => string
39+
/** @defaultValue `true` */
40+
addStarVariantsToDark?: boolean
41+
/** @defaultValue `true` */
42+
addStarVariantsToLight?: boolean
3843
};
3944

4045
/** apply defaults to {@link CSSThemeOptions} */
@@ -50,44 +55,54 @@ export function defaultCSSThemeOptions(options: Partial<CSSThemeOptions> = {}):
5055
}
5156

5257
/** @returns the dark mode selector or media rule */
53-
export function defaultDarkSelector(options: Partial<CSSThemeOptions>) {
54-
const { darkMode = true } = options;
55-
if (darkMode === true || darkMode === '.dark')
56-
return '.dark, .dark *';
57-
else if (darkMode === false || darkMode === '' || darkMode === 'media')
58-
return '@media not print and (prefers-color-scheme: dark)';
59-
else if (!Array.isArray(darkMode)) {
60-
return darkMode.includes(',') ? darkMode : `${darkMode}, ${darkMode} *`;
61-
}
58+
export function defaultDarkSelector(options: Partial<CSSThemeOptions>): string[] {
59+
const { addStarVariantsToDark = true } = options;
60+
let { darkMode = true } = options;
6261

63-
const selectors: string[] = [];
64-
for (const s of darkMode) {
65-
const trimmed = s.trim();
66-
if (trimmed)
67-
selectors.push(trimmed);
68-
}
62+
if (darkMode === true)
63+
darkMode = '.dark';
64+
else if (darkMode === false || darkMode === '')
65+
darkMode = 'media';
6966

70-
if (selectors.length === 0) return '.dark, .dark *';
71-
return selectors;
67+
const result = processCSSSelectors(darkMode, {
68+
addStarVariants: addStarVariantsToDark,
69+
});
70+
return result ?? ['.dark, .dark *'];
7271
}
7372

7473
/** @returns the light mode selector, or undefined if disabled */
75-
export function defaultLightSelector(options: Partial<CSSThemeOptions>) {
76-
const { lightMode = true } = options;
77-
if (lightMode === true || lightMode === '.light')
78-
return '.light, .light *';
74+
export function defaultLightSelector(options: Partial<CSSThemeOptions>): string[] | undefined {
75+
const { addStarVariantsToLight = true } = options;
76+
let { lightMode = true } = options;
77+
78+
if (lightMode === true)
79+
lightMode = '.light';
7980
else if (lightMode === false || lightMode === '')
8081
return undefined;
81-
else
82-
return lightMode.includes(',') ? lightMode : `${lightMode}, ${lightMode} *`;
82+
83+
const result = processCSSSelectors(lightMode, {
84+
addStarVariants: addStarVariantsToLight,
85+
});
86+
return result ?? ['.light, .light *'];
8387
}
8488

85-
export function defaultRootLightSelector(options: Partial<CSSThemeOptions>) {
86-
const rootSelector = ':root';
89+
export function defaultRootLightSelector(options: Partial<CSSThemeOptions>): string[] {
90+
const rootSelector = [':root'];
8791
const lightSelector = defaultLightSelector(options);
92+
8893
if (lightSelector) {
89-
return `${rootSelector}, ${lightSelector}`;
94+
const combined = processCSSSelectors([
95+
...rootSelector,
96+
...lightSelector,
97+
], {
98+
addStarVariants: false, // already added.
99+
});
100+
101+
if (combined) { // always true
102+
return combined;
103+
}
90104
}
105+
91106
return rootSelector;
92107
}
93108

@@ -110,23 +125,17 @@ export function assembleCSSRules(root: CSSRuleObject | undefined,
110125
});
111126
}
112127

113-
styles.push({
114-
...makeDeepRule(rootLightSelector, light),
115-
...makeDeepRule(darkSelector, dark),
116-
});
128+
const combinedRules: CSSRuleObject = {};
117129

118-
return styles;
119-
}
130+
// Set light mode rules
131+
setDeepRule(combinedRules, rootLightSelector, light);
120132

121-
/**
122-
* Creates a nested CSS rule object from a selector path and a CSS rule object
123-
* @param path - Selector path (string or array of strings)
124-
* @param object - CSS rule object to be nested
125-
* @returns A CSSRuleObject with the nested structure
126-
*/
127-
function makeDeepRule(path: string | string[], object: CSSRuleObject): CSSRuleObject {
128-
const out: CSSRuleObject = {};
129-
return setDeepRule(out, path, object);
133+
// Set dark mode rules
134+
setDeepRule(combinedRules, darkSelector, dark);
135+
136+
styles.push(combinedRules);
137+
138+
return styles;
130139
}
131140

132141
export function generateCSSColorVariables<K extends string>(dark: ColorMap<K>, light: ColorMap<K>, options: CSSThemeOptions) {

0 commit comments

Comments
 (0)