Skip to content

Commit b561db6

Browse files
authored
refactor(ui-react): make the colorScheme (previously mode) on the ThemeProvider controlled (#537)
* refactor(ui-react): make the colorScheme (previously mode) on the ThemeProvider controlled Signed-off-by: Simon Bruneaud <simon.bruneaud@ledger.fr> * nx versions Signed-off-by: Simon Bruneaud <simon.bruneaud@ledger.fr> * refactor: update the sandbox react app Signed-off-by: Simon Bruneaud <simon.bruneaud@ledger.fr> * nx plan Signed-off-by: Simon Bruneaud <simon.bruneaud@ledger.fr> --------- Signed-off-by: Simon Bruneaud <simon.bruneaud@ledger.fr>
1 parent 6ecbafb commit b561db6

File tree

10 files changed

+116
-130
lines changed

10 files changed

+116
-130
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@ledgerhq/lumen-ui-react': patch
3+
---
4+
5+
refactor(ui-react): make the colorScheme (previously mode) on the ThemeProvider controlled
6+
BREAKING_CHANGE(ui-react): rename ThemeProvider mode to colorScheme
7+
BREAKING_CHANGE(ui-react): remove useTheme hook

apps/app-sandbox-react/src/app/app.tsx

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,19 @@
1-
import {
2-
Button,
3-
ThemeProvider,
4-
Switch,
5-
useTheme,
6-
} from '@ledgerhq/lumen-ui-react';
7-
8-
const ToggleThemeButton = () => {
9-
const { mode, toggleMode } = useTheme();
10-
return (
11-
<div className='flex flex-row gap-8 text-muted'>
12-
Dark mode
13-
<Switch selected={mode === 'dark'} onChange={toggleMode} />
14-
</div>
15-
);
16-
};
1+
import { Button, ThemeProvider } from '@ledgerhq/lumen-ui-react';
2+
import { useState } from 'react';
173

184
export function App() {
5+
const [colorScheme, setColorScheme] = useState<'light' | 'dark'>('light');
6+
197
return (
20-
<ThemeProvider defaultMode='system'>
8+
<ThemeProvider colorScheme='system'>
219
<div className='flex h-screen w-screen flex-col items-center justify-center bg-muted'>
22-
<ToggleThemeButton />
10+
<Button
11+
onClick={() =>
12+
setColorScheme(colorScheme === 'light' ? 'dark' : 'light')
13+
}
14+
>
15+
Toggle theme
16+
</Button>
2317
<div className='mt-32 flex flex-row gap-2'>
2418
<Button appearance='accent'>Button</Button>
2519
<Button appearance='base'>Button</Button>

apps/app-sandbox-react/tests/treeShaking/main.treeshaking.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const root = document.getElementById('root');
99
if (root) {
1010
createRoot(root).render(
1111
<StrictMode>
12-
<ThemeProvider defaultMode='light'>
12+
<ThemeProvider colorScheme='light'>
1313
<div className='flex h-screen w-screen flex-col items-center justify-center gap-16 bg-base'>
1414
<Incognito size={48} />
1515
<Button appearance='accent'>Tree Shaking Test</Button>

libs/ui-react/.storybook/Decorator.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { Decorator } from '@storybook/react-vite';
22
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3-
import { ThemeMode, ThemeProvider } from '../src/lib/Components/ThemeProvider';
3+
import {
4+
ColorSchemeName,
5+
ThemeProvider,
6+
} from '../src/lib/Components/ThemeProvider';
47

58
const queryClient = new QueryClient({
69
defaultOptions: { queries: { retry: false } },
@@ -22,7 +25,7 @@ const createThemeDecorator = (
2225

2326
return (
2427
<QueryClientProvider client={queryClient}>
25-
<ThemeProvider defaultMode={context.globals.mode as ThemeMode}>
28+
<ThemeProvider colorScheme={context.globals.mode as ColorSchemeName}>
2629
<Story />
2730
</ThemeProvider>
2831
</QueryClientProvider>

libs/ui-react/.storybook/docs/ThemeProvider.mdx

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,52 +3,58 @@ import { Meta } from '@storybook/addon-docs/blocks';
33
<Meta title="Getting Started/Theme Provider" />
44

55
## Ledger Design System Theme Provider
6-
The ThemeProvider from @ledgerhq/lumen-ui-react manages the app’s visual theme (light or dark mode) using React Context.
6+
7+
The ThemeProvider from @ledgerhq/lumen-ui-react manages the app's visual theme (light, dark, or system) and locale.
78
Define the `ThemeProvider` at the top-level of your app.
89

10+
### Color Scheme
11+
12+
The `colorScheme` prop is fully controlled. It accepts `"light"`, `"dark"`, or `"system"` (default).
13+
When set to `"system"`, the theme automatically follows the user's OS preference via `prefers-color-scheme`.
14+
915
```tsx
1016
import { ThemeProvider } from '@ledgerhq/lumen-ui-react';
1117

1218
function AppProviders() {
1319
return (
14-
<ThemeProvider defaultMode="dark">
20+
<ThemeProvider colorScheme="dark">
1521
<App />
1622
</ThemeProvider>
1723
);
1824
}
1925
```
2026

21-
Updates the mode using the `useTheme` context hook.
22-
```tsx
23-
import { ThemeProvider, useTheme } from '@ledgerhq/lumen-ui-react';
27+
To let users toggle the theme, manage the state in your app and pass it down:
2428

25-
const UpdateThemeModeButton = () => {
26-
const { mode, toggleMode, setMode } = useTheme();
27-
return (
28-
<Button onClick={toggleMode}>
29-
{mode}
30-
</Button>
31-
);
32-
};
29+
```tsx
30+
import { useState } from 'react';
31+
import { ThemeProvider, type ColorSchemeName } from '@ledgerhq/lumen-ui-react';
3332

3433
function AppProviders() {
34+
const [colorScheme, setColorScheme] = useState<ColorSchemeName>('system');
35+
3536
return (
36-
<ThemeProvider mode="dark">
37-
<UpdateThemeModeButton />
37+
<ThemeProvider colorScheme={colorScheme}>
38+
<Button onClick={() => setColorScheme(prev => prev === 'dark' ? 'light' : 'dark')}>
39+
Toggle theme
40+
</Button>
3841
</ThemeProvider>
3942
);
4043
}
4144
```
4245

43-
## Internationalization
44-
The ThemeProvider also manages the app’s locale using React Context.
45-
You can just pass the locale to the `ThemeProvider` and the translations will be lazy-loaded automatically.
46+
### Internationalization
47+
48+
The ThemeProvider also manages the app's locale.
49+
Pass the `locale` prop and translations will be lazy-loaded automatically.
4650

4751
```tsx
52+
import { useState } from 'react';
4853
import { ThemeProvider, type SupportedLocale, Languages } from '@ledgerhq/lumen-ui-react';
4954

5055
function AppProviders() {
5156
const [locale, setLocale] = useState<SupportedLocale>(Languages.en.id);
57+
5258
return (
5359
<ThemeProvider locale={locale}>
5460
<App />

libs/ui-react/src/lib/Components/Button/Button.stories.tsx

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -135,19 +135,21 @@ export const ContentTypesShowcase: Story = {
135135
};
136136

137137
export const SizesShowcase: Story = {
138-
render: () => (
139-
<div className='flex items-center gap-4'>
140-
<Button appearance='base' size='sm'>
141-
Small
142-
</Button>
143-
<Button appearance='base' size='md'>
144-
Medium
145-
</Button>
146-
<Button appearance='base' size='lg' icon={Settings}>
147-
Large
148-
</Button>
149-
</div>
150-
),
138+
render: () => {
139+
return (
140+
<div className='flex items-center gap-4'>
141+
<Button appearance='base' size='sm'>
142+
Small
143+
</Button>
144+
<Button appearance='base' size='md'>
145+
Medium
146+
</Button>
147+
<Button appearance='base' size='lg' icon={Settings}>
148+
Large
149+
</Button>
150+
</div>
151+
);
152+
},
151153
};
152154

153155
export const StatesShowcase: Story = {

libs/ui-react/src/lib/Components/ThemeProvider/ThemeProvider.test.tsx

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { fireEvent, render, screen } from '@testing-library/react';
1+
import { render } from '@testing-library/react';
22
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
33
import '@testing-library/jest-dom';
44

5-
import { ThemeProvider, useTheme } from './ThemeProvider';
5+
import { ThemeProvider } from './ThemeProvider';
66

77
const root = document.documentElement;
88

@@ -33,7 +33,7 @@ afterEach(() => {
3333
describe('ThemeProvider', () => {
3434
it('applies light class when theme is light', () => {
3535
render(
36-
<ThemeProvider defaultMode='light'>
36+
<ThemeProvider colorScheme='light'>
3737
<div data-testid='child' />
3838
</ThemeProvider>,
3939
);
@@ -44,7 +44,7 @@ describe('ThemeProvider', () => {
4444

4545
it('applies dark class when theme is dark', () => {
4646
render(
47-
<ThemeProvider defaultMode='dark'>
47+
<ThemeProvider colorScheme='dark'>
4848
<div data-testid='child' />
4949
</ThemeProvider>,
5050
);
@@ -57,7 +57,7 @@ describe('ThemeProvider', () => {
5757
setupMatchMedia(true);
5858

5959
render(
60-
<ThemeProvider defaultMode='system'>
60+
<ThemeProvider colorScheme='system'>
6161
<div data-testid='child' />
6262
</ThemeProvider>,
6363
);
@@ -70,35 +70,12 @@ describe('ThemeProvider', () => {
7070
setupMatchMedia(false);
7171

7272
render(
73-
<ThemeProvider defaultMode='system'>
73+
<ThemeProvider colorScheme='system'>
7474
<div data-testid='child' />
7575
</ThemeProvider>,
7676
);
7777

7878
expect(root).toHaveClass('light');
7979
expect(root).not.toHaveClass('dark');
8080
});
81-
82-
it('updates class using useTheme context', () => {
83-
const Consumer = () => {
84-
const { mode, setMode } = useTheme();
85-
return (
86-
<div data-testid='theme-value' onClick={() => setMode('light')}>
87-
{mode}
88-
</div>
89-
);
90-
};
91-
92-
render(
93-
<ThemeProvider defaultMode='dark'>
94-
<Consumer />
95-
</ThemeProvider>,
96-
);
97-
98-
expect(screen.getByTestId('theme-value')).toHaveTextContent('dark');
99-
fireEvent.click(screen.getByTestId('theme-value'));
100-
expect(screen.getByTestId('theme-value')).toHaveTextContent('light');
101-
expect(root).toHaveClass('light');
102-
expect(root).not.toHaveClass('dark');
103-
});
10481
});
Lines changed: 9 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,29 @@
11
import { createSafeContext } from '@ledgerhq/lumen-utils-shared';
2-
import { FC, useMemo, useState } from 'react';
2+
import { FC, useMemo } from 'react';
33
import { I18nProvider } from '../../../i18n';
4-
import { ThemeMode, ThemeProviderProps } from './ThemeProvider.types';
5-
import {
6-
DARK_MODE,
7-
LIGHT_MODE,
8-
SYSTEM_MODE,
9-
useRootColorModeSideEffect,
10-
} from './useRootColorModeSideEffect';
4+
import { COLOR_SCHEMES, ThemeProviderProps } from './ThemeProvider.types';
5+
import { useRootColorModeSideEffect } from './useRootColorModeSideEffect';
116

127
type ThemeProviderState = {
13-
mode: ThemeMode;
14-
setMode: (mode: ThemeMode) => void;
158
locale: ThemeProviderProps['locale'];
169
};
1710

18-
const [ThemeProviderContext, useThemeContext] =
11+
const [ThemeProviderContext] =
1912
createSafeContext<ThemeProviderState>('ThemeProvider');
2013

21-
const ThemeProvider: FC<ThemeProviderProps> = ({
14+
export const ThemeProvider: FC<ThemeProviderProps> = ({
2215
children,
23-
defaultMode = SYSTEM_MODE,
16+
colorScheme = COLOR_SCHEMES.system,
2417
locale,
2518
}) => {
26-
const [mode, setMode] = useState(defaultMode);
27-
28-
useRootColorModeSideEffect({ mode });
19+
useRootColorModeSideEffect({ colorScheme });
2920

3021
const value = useMemo(
3122
() => ({
32-
mode,
33-
setMode,
23+
colorScheme,
3424
locale,
3525
}),
36-
[mode, locale],
26+
[colorScheme, locale],
3727
);
3828

3929
return (
@@ -42,17 +32,3 @@ const ThemeProvider: FC<ThemeProviderProps> = ({
4232
</ThemeProviderContext>
4333
);
4434
};
45-
46-
const useTheme = () => {
47-
const context = useThemeContext({
48-
consumerName: 'useTheme',
49-
contextRequired: true,
50-
});
51-
return {
52-
...context,
53-
toggleMode: () =>
54-
context.setMode(context.mode === DARK_MODE ? LIGHT_MODE : DARK_MODE),
55-
};
56-
};
57-
58-
export { ThemeProvider, useTheme };

libs/ui-react/src/lib/Components/ThemeProvider/ThemeProvider.types.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
import { PropsWithChildren } from 'react';
22
import { type SupportedLocale } from '../../../i18n';
33

4-
export type ThemeMode = 'dark' | 'light' | 'system';
4+
export const COLOR_SCHEMES = {
5+
light: 'light',
6+
dark: 'dark',
7+
system: 'system',
8+
} as const;
9+
10+
export type ColorSchemeName =
11+
(typeof COLOR_SCHEMES)[keyof typeof COLOR_SCHEMES];
512

613
export type ThemeProviderProps = PropsWithChildren & {
714
/**
8-
* The default mode of the theme.
15+
* The color scheme of the theme.
16+
* system will follow the user's OS preference via `prefers-color-scheme`.
917
* @default 'system'
1018
*/
11-
defaultMode?: ThemeMode;
19+
colorScheme?: ColorSchemeName;
1220
/**
1321
* The locale to use for translations.
1422
* When changed, translations will be lazy-loaded automatically.

0 commit comments

Comments
 (0)