Skip to content

Commit 0a1bf8d

Browse files
committed
feat(styles): add theme extension support and improve nested theme composition
- Add support for extending FusionTheme with custom properties using generics - Enhance createTheme to accept optional baseTheme parameter for nested themes - Improve deep merging to properly handle StyleProperty instances and Record types - Export ThemeProviderProps and StylesProviderProps interfaces for better TypeScript support - Add explicit exports for createTheme, FusionTheme, and StyleDefinition - Update tests to use createTheme instead of plain objects - Fix theme merging in nested ThemeProvider scenarios All changes are backward compatible. Signed-off-by: Odin Thomas Rochmann <odin.rochmann@gmail.com>
1 parent 2a0f03e commit 0a1bf8d

File tree

10 files changed

+387
-47
lines changed

10 files changed

+387
-47
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@equinor/fusion-react-styles": minor
3+
---
4+
5+
Enhanced theme system with support for extending `FusionTheme` with custom properties and improved nested theme composition.
6+
7+
### Added
8+
9+
- **Theme Extension Support**: `FusionTheme` now supports extending with custom properties using generics:
10+
```typescript
11+
type MyTheme = FusionTheme<{ colors: { primary: ColorStyleProperty } }>;
12+
```
13+
- **Custom Base Theme Merging**: `createTheme` now accepts an optional `baseTheme` parameter for merging with custom base themes:
14+
```typescript
15+
const extendedTheme = createTheme(
16+
{ colors: { ui: { background__danger: newColor } } },
17+
outerTheme
18+
);
19+
```
20+
- **Deep Merging Improvements**: Enhanced `deepMerge` function properly handles nested theme properties, `Record` types, and `StyleProperty` instances
21+
- **Type Exports**: Explicitly exported `ThemeProviderProps`, `StylesProviderProps`, `FusionTheme`, `StyleDefinition`, and `createTheme` for better TypeScript support
22+
23+
### Changed
24+
25+
- `createTheme` signature now accepts optional `baseTheme` parameter (backward compatible)
26+
- Improved type inference for extended themes in `ThemeProvider`, `useTheme`, and `makeStyles`
27+
- Better handling of nested theme composition when using theme functions in nested `ThemeProvider` components
28+
29+
### Technical Details
30+
31+
- Deep merging now correctly handles `StyleProperty` instances (replaces instead of merging)
32+
- Theme composition works correctly with nested `ThemeProvider` components
33+
- All types are properly exported and documented

packages/styles/src/ThemeProvider.tsx

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useContext, useMemo, type ReactNode } from 'react';
22
import type { ReactElement } from 'react';
33
import { styles as defaultTheme } from '@equinor/fusion-web-theme';
44
import { ThemeContext } from './utils/contexts';
5+
import type { FusionTheme } from './theme';
56

67
import '@equinor/fusion-wc-theme';
78
import type ThemeElement from '@equinor/fusion-wc-theme';
@@ -15,12 +16,14 @@ declare module 'react' {
1516

1617
/**
1718
* Props for the ThemeProvider component
19+
*
20+
* @template T - Extended theme type that extends FusionTheme
1821
*/
19-
export interface ThemeProviderProps {
22+
export interface ThemeProviderProps<T extends FusionTheme = FusionTheme> {
2023
/** Child components that will receive the theme context */
2124
children?: ReactNode;
2225
/** Theme object or function that receives outer theme and returns new theme */
23-
theme: unknown | ((outerTheme: unknown) => unknown);
26+
theme?: T | Partial<T> | ((outerTheme: T | null) => T);
2427
}
2528

2629
/**
@@ -30,6 +33,9 @@ export interface ThemeProviderProps {
3033
* When nested, the theme can be a function that receives the outer theme and returns
3134
* a merged or customized theme.
3235
*
36+
* Supports extending FusionTheme with custom properties for application-specific themes.
37+
*
38+
* @template T - Extended theme type that extends FusionTheme
3339
* @param props - Theme provider configuration
3440
* @returns A React element that provides theme context to children
3541
*
@@ -49,20 +55,40 @@ export interface ThemeProviderProps {
4955
* </ThemeProvider>
5056
* </ThemeProvider>
5157
* ```
58+
*
59+
* @example
60+
* ```tsx
61+
* // Extended theme with custom properties
62+
* interface MyAppTheme extends FusionTheme {
63+
* customProperty: string;
64+
* }
65+
*
66+
* const extendedTheme: MyAppTheme = {
67+
* ...theme,
68+
* customProperty: 'value'
69+
* };
70+
*
71+
* <ThemeProvider<MyAppTheme> theme={extendedTheme}>
72+
* <App />
73+
* </ThemeProvider>
74+
* ```
5275
*/
53-
export function ThemeProvider(props: ThemeProviderProps): ReactElement {
76+
export function ThemeProvider<T extends FusionTheme = FusionTheme>(
77+
props: ThemeProviderProps<T>,
78+
): ReactElement {
5479
const { children, theme: localTheme } = props;
5580
// Get theme from parent ThemeProvider (if nested)
56-
const outerTheme = useContext(ThemeContext);
81+
const outerTheme = useContext(ThemeContext) as T | null;
5782

5883
// Resolve theme: if function, call with outer theme; otherwise use directly or default
59-
const theme = useMemo(() => {
84+
const theme = useMemo((): T => {
6085
if (typeof localTheme === 'function') {
6186
// Theme function receives outer theme and returns new theme (enables theme composition)
6287
return localTheme(outerTheme);
6388
}
64-
// Use provided theme or fall back to default
65-
return localTheme ?? defaultTheme;
89+
// Use provided theme as-is, or fall back to default theme
90+
// Note: Partial themes will be merged at the type level, but runtime uses provided theme directly
91+
return (localTheme ?? defaultTheme) as T;
6692
}, [localTheme, outerTheme]);
6793

6894
return (
@@ -76,18 +102,34 @@ export function ThemeProvider(props: ThemeProviderProps): ReactElement {
76102
/**
77103
* Hook to access the current theme from ThemeProvider context
78104
*
79-
* @template Theme - The type of the theme (defaults to unknown)
105+
* Supports extended themes that extend FusionTheme. When used with an extended theme,
106+
* the generic type parameter should match the theme type used in ThemeProvider.
107+
*
108+
* @template Theme - The type of the theme (defaults to FusionTheme, but can be extended)
80109
* @returns The current theme value or null if no ThemeProvider is present
81110
*
82111
* @example
83112
* ```tsx
84113
* function Component() {
85-
* const theme = useTheme<MyTheme>();
114+
* const theme = useTheme();
86115
* return <div style={{ color: theme?.colors.primary }}>Hello</div>;
87116
* }
88117
* ```
118+
*
119+
* @example
120+
* ```tsx
121+
* // With extended theme type
122+
* interface MyAppTheme extends FusionTheme {
123+
* customProperty: string;
124+
* }
125+
*
126+
* function Component() {
127+
* const theme = useTheme<MyAppTheme>();
128+
* return <div>{theme?.customProperty}</div>;
129+
* }
130+
* ```
89131
*/
90-
export function useTheme<Theme = unknown>(): Theme | null {
132+
export function useTheme<Theme extends FusionTheme = FusionTheme>(): Theme | null {
91133
const theme = useContext(ThemeContext);
92134
return theme as Theme | null;
93135
}

packages/styles/src/__tests__/StyleProvider.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@ import { createGenerateClassName } from '../utils/class-name-generator';
1818
import { StylesContext } from '../utils/contexts';
1919
import { makeStyles } from '../make-styles';
2020
import { ThemeProvider } from '../ThemeProvider';
21+
import { createTheme } from '../theme';
2122

22-
const mockTheme = {
23+
const mockTheme = createTheme({
2324
colors: {
2425
primary: 'blue',
2526
},
26-
};
27+
});
2728

2829
beforeEach(() => {
2930
document.head.innerHTML = '';

packages/styles/src/__tests__/ThemeProvider.test.tsx

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,19 @@ import { render, screen, renderHook } from '@testing-library/react';
1515
import { useContext } from 'react';
1616
import { ThemeProvider, useTheme } from '../ThemeProvider';
1717
import { ThemeContext } from '../utils/contexts';
18+
import type { FusionTheme } from '../theme';
19+
import { createTheme, theme } from '../theme';
20+
import { makeStyles } from '../make-styles';
21+
import { ColorStyleProperty } from '@equinor/fusion-web-theme/dist/styles/colors';
1822

1923
// Mock theme - simulates Fusion design system theme
20-
const mockTheme = {
24+
// Using createTheme to extend FusionTheme with custom colors
25+
const mockTheme = createTheme({
2126
colors: {
2227
primary: 'blue',
2328
secondary: 'red',
2429
},
25-
};
30+
});
2631

2732
describe('ThemeProvider - Theme distribution', () => {
2833
it('should render child components normally', () => {
@@ -83,16 +88,27 @@ describe('ThemeProvider - Theme distribution', () => {
8388
// WHY: Enables theme composition - extend or override parent theme
8489
// EXAMPLE: Parent provides base theme, child extends it with custom colors
8590

86-
const themeFunction = (outerTheme: unknown) => {
87-
return { ...mockTheme, outer: outerTheme };
91+
const themeFunction = (outerTheme: unknown): FusionTheme => {
92+
return { ...mockTheme, outer: outerTheme } as unknown as FusionTheme;
8893
};
8994

95+
const useStyles = makeStyles(
96+
(theme: FusionTheme) => {
97+
// Verify theme function was called and theme is available
98+
expect(theme).toBeDefined();
99+
expect(theme.outer).toBeDefined();
100+
return {
101+
root: {
102+
color: 'blue',
103+
},
104+
};
105+
},
106+
{ name: 'ThemeFunctionTest' },
107+
);
108+
90109
const TestComponent = () => {
91-
const theme = useContext(ThemeContext);
92-
// Verify theme function was called and returned theme
93-
expect(theme).toBeDefined();
94-
expect((theme as { outer: unknown }).outer).toBeDefined();
95-
return <div>Test</div>;
110+
const classes = useStyles({});
111+
return <div className={classes.root}>Test</div>;
96112
};
97113

98114
render(
@@ -107,14 +123,49 @@ describe('ThemeProvider - Theme distribution', () => {
107123
// WHY: Enables theme composition - child themes can extend parent
108124
// EXAMPLE: App theme defines colors, module theme extends with module-specific colors
109125

110-
const outerTheme = { outer: 'theme' };
111-
const themeFunction = (outer: unknown) => {
126+
const background__danger = new ColorStyleProperty('background__danger', {
127+
hex: '#000000',
128+
hsla: 'hsla(0, 0%, 0%, 1)',
129+
rgba: 'rgba(0, 0, 0, 1)',
130+
});
131+
132+
const outerTheme = createTheme();
133+
134+
const themeFunction = (providedTheme: FusionTheme | null): FusionTheme => {
112135
// Verify outer theme is passed correctly
113-
expect(outer).toBe(outerTheme);
114-
return { ...mockTheme, outer };
136+
expect(providedTheme).toBe(outerTheme);
137+
expect(providedTheme?.colors.ui.background__danger.value.hex).toBe(
138+
theme?.colors.ui.background__danger.value.hex,
139+
);
140+
expect(providedTheme?.colors.ui.background__danger.value.hsla).toBe(
141+
theme?.colors.ui.background__danger.value.hsla,
142+
);
143+
expect(providedTheme?.colors.ui.background__danger.value.rgba).toBe(
144+
theme?.colors.ui.background__danger.value.rgba,
145+
);
146+
147+
// Child theme extends outer theme with additional colors
148+
// Pass outer theme as baseTheme to merge with outer theme instead of default theme
149+
return createTheme(
150+
{
151+
colors: {
152+
ui: {
153+
background__danger: background__danger,
154+
},
155+
},
156+
},
157+
providedTheme ?? undefined,
158+
);
115159
};
116160

117161
const TestComponent = () => {
162+
const componentTheme = useTheme();
163+
// Verify the merged theme is available
164+
expect(componentTheme).toBeDefined();
165+
// Verify the custom background__danger was merged correctly
166+
expect(componentTheme?.colors.ui.background__danger.css).toBe(background__danger.css);
167+
// Verify other UI colors from base theme are still present
168+
expect(componentTheme?.colors.ui.background__default).toBeDefined();
118169
return <div>Test</div>;
119170
};
120171

@@ -188,7 +239,12 @@ describe('useTheme hook - Accessing theme from components', () => {
188239
// WHY: Type safety when accessing theme properties
189240
// EXAMPLE: useTheme<MyTheme>() gives typed access to theme.colors.primary
190241

191-
type CustomTheme = typeof mockTheme;
242+
type CustomTheme = FusionTheme & {
243+
colors: {
244+
primary: string;
245+
secondary: string;
246+
};
247+
};
192248

193249
const wrapper = ({ children }: { children: React.ReactNode }) => (
194250
<ThemeProvider theme={mockTheme}>{children}</ThemeProvider>
@@ -198,16 +254,21 @@ describe('useTheme hook - Accessing theme from components', () => {
198254

199255
// Verify typed theme is returned
200256
expect(result.current).toBe(mockTheme);
201-
// TypeScript now knows theme structure
202-
expect(result.current?.colors.primary).toBe('blue');
257+
// TypeScript now knows theme structure - cast to access test-specific properties
258+
const theme = result.current as unknown as { colors?: { primary?: string } };
259+
expect(theme.colors?.primary).toBe('blue');
203260
});
204261

205262
it('should update when theme changes', () => {
206263
// WHAT: useTheme updates when ThemeProvider theme changes
207264
// WHY: Supports dynamic theming (e.g., switching light/dark mode)
208265
// EXAMPLE: User toggles theme, all components get new theme
209266

210-
const newTheme = { colors: { primary: 'green' } };
267+
const newTheme = createTheme({
268+
colors: {
269+
primary: 'green',
270+
},
271+
});
211272

212273
const wrapper = ({ children }: { children: React.ReactNode }) => (
213274
<ThemeProvider theme={newTheme}>{children}</ThemeProvider>

packages/styles/src/__tests__/make-styles.test.tsx

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,28 @@ import { makeStyles } from '../make-styles';
1717
import { ThemeProvider } from '../ThemeProvider';
1818
import { StylesProvider } from '../StyleProvider';
1919
import type { Styles } from '../types';
20+
import { createTheme, type FusionTheme } from '../theme';
21+
import { ColorStyleProperty, type Color } from '@equinor/fusion-web-theme/dist/styles/colors';
2022

2123
// Mock theme object - simulates the Fusion design system theme
22-
const mockTheme = {
24+
// Using createTheme to extend FusionTheme with custom colors.primary property
25+
const mockTheme = createTheme({
2326
colors: {
24-
primary: 'blue',
25-
secondary: 'red',
27+
primary: new ColorStyleProperty('primary', {
28+
hex: '#0000ff',
29+
hsla: 'hsla(240, 100%, 50%, 1)',
30+
rgba: 'rgba(0, 0, 255, 1)',
31+
} satisfies Color),
2632
},
27-
};
33+
});
34+
35+
// Type for theme with string colors (for simpler test cases)
36+
type StringColorTheme = FusionTheme<{
37+
colors: {
38+
primary: string;
39+
secondary: string;
40+
};
41+
}>;
2842

2943
describe('makeStyles - Main styling API', () => {
3044
beforeEach(() => {
@@ -114,20 +128,29 @@ describe('makeStyles - Main styling API', () => {
114128
// EXAMPLE: Components that use theme.colors.primary instead of hardcoded colors
115129

116130
// Styles can be a function that receives theme
117-
const styles: Styles<typeof mockTheme, Record<string, unknown>> = (
118-
theme: typeof mockTheme,
119-
) => ({
120-
root: {
121-
color: theme.colors.primary, // Uses theme value
122-
backgroundColor: theme.colors.secondary,
131+
// Create a theme with string colors for this test
132+
const stringColorTheme = createTheme({
133+
colors: {
134+
primary: 'blue',
135+
secondary: 'red',
123136
},
124137
});
125138

139+
const styles: Styles<StringColorTheme, Record<string, unknown>> = (theme: StringColorTheme) => {
140+
const themeColors = theme.colors;
141+
return {
142+
root: {
143+
color: themeColors.primary || 'blue', // Uses theme value
144+
backgroundColor: themeColors.secondary || 'red',
145+
},
146+
};
147+
};
148+
126149
const useStyles = makeStyles(styles, { name: 'ThemeComponent' });
127150

128151
const wrapper = ({ children }: { children: React.ReactNode }) => (
129152
<StylesProvider>
130-
<ThemeProvider theme={mockTheme}>{children}</ThemeProvider>
153+
<ThemeProvider theme={stringColorTheme}>{children}</ThemeProvider>
131154
</StylesProvider>
132155
);
133156

0 commit comments

Comments
 (0)