Skip to content

Commit

Permalink
πŸ’… Restore theming support (#2714)
Browse files Browse the repository at this point in the history
* refactor: initial

* chore: storybook config

* chore: changeset

* chore: improvements

* refactor: cleanup

* refactor: use new tokens

* feat: leftovers

* feat: further colors

* feat: further colors

* refactor: design tokens cleanup

* chore: minor not patch

* chore: minor not patch again

---------

Co-authored-by: Carlos Cortizas <97907068+CarlosCortizasCT@users.noreply.github.com>
  • Loading branch information
kark and CarlosCortizasCT committed Feb 13, 2024
1 parent db371ba commit 568c28e
Show file tree
Hide file tree
Showing 10 changed files with 236 additions and 11 deletions.
6 changes: 6 additions & 0 deletions .changeset/purple-snails-do.md
@@ -0,0 +1,6 @@
---
'visual-testing-app': minor
'@commercetools-uikit/design-system': minor
---

Restore theming support
32 changes: 32 additions & 0 deletions design-system/materials/internals/definition.yaml
Expand Up @@ -264,6 +264,38 @@ choiceGroupsByTheme:
break-point-giantdesktop: 1680px
break-point-jumbodesktop: 1920px

recolouring:
colors:
label: Colors
prefix: color
description: All colors in the system
choices:
color-primary: 'hsl(240, 64%, 58%)'
color-primary-10: 'hsl(240, 66%, 19%)'
color-primary-20: 'hsl(240, 45%, 33%)'
color-primary-25: 'hsl(240, 46%, 48%)'
color-primary-30: 'hsl(240, 46%, 53%)'
color-primary-40: 'hsl(240, 100%, 67%)'
color-primary-85: 'hsl(244, 100%, 84%)'
color-primary-90: 'hsl(243, 100%, 93%)'
color-primary-95: 'hsl(244, 100%, 97%)'
color-success: 'hsl(152, 77%, 39%)'
color-success-25: 'hsl(155, 84%, 20%)'
color-success-40: 'hsl(155, 90%, 24%)'
color-success-85: 'hsl(144, 69%, 80%)'
color-success-95: 'hsl(141, 76%, 92%)'
color-warning: 'hsl(35, 90%, 45%)'
color-warning-25: 'hsl(33, 83%, 25%)'
color-warning-40: 'hsl(33, 80%, 34%)'
color-warning-60: 'hsl(35, 90%, 55%)'
color-warning-85: 'hsl(33, 90%, 80%)'
color-warning-95: 'hsl(45, 100%, 92%)'
color-error: 'hsl(3, 65%, 58%)'
color-error-25: 'hsl(4, 69%, 37%)'
color-error-40: 'hsl(3, 60%, 46%)'
color-error-85: 'hsl(1, 55%, 74%)'
color-error-95: 'hsl(349, 66%, 92%)'

states:
active:
description: 'Trigged while the user is currently interacting with the element'
Expand Down
27 changes: 27 additions & 0 deletions design-system/src/design-tokens.ts
Expand Up @@ -235,6 +235,33 @@ export const themes = {
shadowForInputWhenError: 'inset 0 0 0 1px var(--color-error)',
shadowForInputWhenWarning: 'inset 0 0 0 1px var(--color-warning)',
},
recolouring: {
colorPrimary: 'hsl(240, 64%, 58%)',
colorPrimary10: 'hsl(240, 66%, 19%)',
colorPrimary20: 'hsl(240, 45%, 33%)',
colorPrimary25: 'hsl(240, 46%, 48%)',
colorPrimary30: 'hsl(240, 46%, 53%)',
colorPrimary40: 'hsl(240, 100%, 67%)',
colorPrimary85: 'hsl(244, 100%, 84%)',
colorPrimary90: 'hsl(243, 100%, 93%)',
colorPrimary95: 'hsl(244, 100%, 97%)',
colorSuccess: 'hsl(152, 77%, 39%)',
colorSuccess25: 'hsl(155, 84%, 20%)',
colorSuccess40: 'hsl(155, 90%, 24%)',
colorSuccess85: 'hsl(144, 69%, 80%)',
colorSuccess95: 'hsl(141, 76%, 92%)',
colorWarning: 'hsl(35, 90%, 45%)',
colorWarning25: 'hsl(33, 83%, 25%)',
colorWarning40: 'hsl(33, 80%, 34%)',
colorWarning60: 'hsl(35, 90%, 55%)',
colorWarning85: 'hsl(33, 90%, 80%)',
colorWarning95: 'hsl(45, 100%, 92%)',
colorError: 'hsl(3, 65%, 58%)',
colorError25: 'hsl(4, 69%, 37%)',
colorError40: 'hsl(3, 60%, 46%)',
colorError85: 'hsl(1, 55%, 74%)',
colorError95: 'hsl(349, 66%, 92%)',
},
} as const;

const designTokens = {
Expand Down
42 changes: 37 additions & 5 deletions design-system/src/theme-provider.tsx
@@ -1,13 +1,16 @@
import {
useLayoutEffect,
useRef,
useState,
useCallback,
useEffect,
type ReactNode,
type JSXElementConstructor,
} from 'react';
import isObject from 'lodash/isObject';
import merge from 'lodash/merge';
import isEqual from 'lodash/isEqual';
import { useMutationObserver } from '@commercetools-uikit/hooks';
import { themes } from './design-tokens';
import { transformTokensToCssVarsValues } from './utils';

Expand Down Expand Up @@ -100,7 +103,6 @@ ThemeProvider.defaultProps = {

type TUseThemeResult = {
theme: ThemeName;
/** @deprecated */
themedValue: <
Old extends string | ReactNode | undefined,
New extends string | ReactNode | undefined
Expand All @@ -110,17 +112,47 @@ type TUseThemeResult = {
) => Old | New;
/** @deprecated */
isNewTheme: boolean;
isRecolouringTheme: boolean;
};
const useTheme = (_parentSelector = defaultParentSelector): TUseThemeResult => {
const useTheme = (parentSelector = defaultParentSelector): TUseThemeResult => {
const [theme, setTheme] = useState<ThemeName>('default');
const parentSelectorRef = useRef(parentSelector);

const mutationChangeCallback = useCallback((mutationList) => {
// We expect only a single element in the mutation list as we configured the
// observer to only listen to `data-theme` changes.
const [mutationEvent] = mutationList;
setTheme((mutationEvent.target as HTMLElement).dataset.theme as ThemeName);
}, []);

useMutationObserver(parentSelector(), mutationChangeCallback, {
attributes: true,
attributeFilter: ['data-theme'],
});

const themedValue: TUseThemeResult['themedValue'] = useCallback(
(_defaultThemeValue, newThemeValue) => newThemeValue,
[]
(defaultThemeValue, newThemeValue) =>
theme === 'default' ? defaultThemeValue : newThemeValue,
[theme]
);

// If we use 'useLayoutEffect' here, we would be trying to read the
// data attribute before it gets set from the effect in the ThemeProvider
useEffect(() => {
// We need to read the current theme after the provider is rendered
// to have the actual selected theme (calculated client-side) in the
// hook local state
const nextTheme = parentSelectorRef.current()?.dataset.theme as ThemeName;
if (nextTheme) {
setTheme(nextTheme);
}
}, []);

return {
theme: 'default',
themedValue,
isNewTheme: true,
isNewTheme: false,
isRecolouringTheme: theme === 'recolouring',
};
};

Expand Down
62 changes: 59 additions & 3 deletions design-system/src/theme-provider.visualroute.jsx
@@ -1,3 +1,4 @@
import { useState } from 'react';
import { useTheme, designTokens } from '@commercetools-uikit/design-system';
import { Switch, Route } from 'react-router';
import kebabCase from 'lodash/kebabCase';
Expand All @@ -8,16 +9,15 @@ import {
LocalDarkThemeProvider,
LocalThemeProvider,
} from '../../test/percy';
import { ThemeProvider } from './theme-provider';

export const routePath = '/theme-provider';

const parentSelector = (id) => () => document.getElementById(id);

const DummyComponent = (props) => {
const { theme } = useTheme(
props.parentId
? parentSelector(props.parentId)
: undefined
props.parentId ? parentSelector(props.parentId) : undefined
);

return (
Expand Down Expand Up @@ -135,8 +135,64 @@ TestComponent.propTypes = {
text: PropTypes.string.isRequired,
};

const localThemeParentSelector = () => document.getElementById('local');

const InteractiveRoute = () => {
const [globalTheme, setGlobalTheme] = useState({
name: 'default',
overrides: {},
});
const [localTheme, setLocalTheme] = useState({
name: 'default',
overrides: {},
});

return (
<>
<button
onClick={() => {
setGlobalTheme({
name: 'recolouring',
overrides: {
colorSolid: 'red',
colorSurface: 'yellow',
customColor: '#BADA55',
},
});
}}
>
change global theme
</button>
<button
onClick={() => {
setLocalTheme({
name: 'recolouring',
overrides: { colorSolid: 'green', colorSurface: 'tomato' },
});
}}
>
change local theme
</button>
<ThemeProvider
theme={globalTheme.name}
themeOverrides={globalTheme.overrides}
/>
<TestComponent text="global" />
<div id="local">
<ThemeProvider
theme={localTheme.name}
themeOverrides={localTheme.overrides}
parentSelector={localThemeParentSelector}
/>
<TestComponent text="local" />
</div>
</>
);
};

export const component = () => (
<Switch>
<Route path={`${routePath}/interactive`} component={InteractiveRoute} />
<Route path={routePath} component={DefaultRoute} />
</Switch>
);
28 changes: 28 additions & 0 deletions design-system/src/theme-provider.visualspec.js
@@ -1,4 +1,5 @@
import percySnapshot from '@percy/puppeteer';
import { getDocument, queries } from 'pptr-testing-library';

describe('ThemeProvider', () => {
it('Default', async () => {
Expand All @@ -7,3 +8,30 @@ describe('ThemeProvider', () => {
await percySnapshot(page, 'ThemeProvider');
});
});
describe('Interactive', () => {
it('applies changes to global and local theme provider', async () => {
await page.goto(`${globalThis.HOST}/theme-provider/interactive`);
const doc = await getDocument(page);

// change global theme
const globalThemeChangeButton = await queries.findByText(
doc,
'change global theme'
);

await globalThemeChangeButton.click();
await page.waitForSelector('[data-theme="recolouring"]');
// TODO: uncomment when issue with Percy is resolved
// await percySnapshot(page, 'ThemeProvider - after global theme change');

// change local theme
const localThemeChangeButton = await queries.findByText(
doc,
'change local theme'
);
await localThemeChangeButton.click();
await page.waitForSelector('[data-theme="recolouring"]');
// TODO: uncomment when issue with Percy is resolved
// await percySnapshot(page, 'ThemeProvider - after local theme change');
});
});
3 changes: 2 additions & 1 deletion docs/.storybook/configs/contexts.js
@@ -1,3 +1,4 @@
import intlContext from './intl-context';
import themeContext from './theme-context';

export const contexts = [intlContext];
export const contexts = [intlContext, themeContext];
43 changes: 43 additions & 0 deletions docs/.storybook/configs/theme-context.js
@@ -0,0 +1,43 @@
import PropTypes from 'prop-types';
import { ThemeProvider } from '../../../design-system';

const ThemeWrapper = (props) => {
return (
<>
<ThemeProvider
theme={props.themeName}
themeOverrides={props.themeOverrides}
/>
{props.children}
</>
);
};

ThemeWrapper.propTypes = {
theme: PropTypes.any,
};

const themeParams = [
{
name: 'Default Theme',
props: { themeName: 'default' },
},
{
name: 'Custom Theme',
props: { themeName: 'recolouring' },
},
];

const themeContext = {
icon: 'box', // a icon displayed in the Storybook toolbar to control contextual props
title: 'Themes', // an unique name of a contextual environment
components: [ThemeWrapper],
params: themeParams,
options: {
deep: true, // pass the `props` deeply into all wrapping components
disable: false, // disable this contextual environment completely
cancelable: false, // allow this contextual environment to be opt-out optionally in toolbar
},
};

export default themeContext;
2 changes: 1 addition & 1 deletion docs/.storybook/decorators/theme-wrapper/theme-wrapper.js
Expand Up @@ -4,7 +4,7 @@ import { ThemeProvider } from '../../../../design-system';
const ThemeWrapper = (storyFn) => {
return (
<>
<ThemeProvider theme="test" />
<ThemeProvider />
{storyFn()}
</>
);
Expand Down
2 changes: 1 addition & 1 deletion visual-testing-app/src/App.jsx
Expand Up @@ -29,7 +29,7 @@ const allSortedComponents = Object.keys(allUniqueRouteComponents)

const App = () => (
<>
<ThemeProvider theme="test" />
<ThemeProvider />
<Router>
<Switch>
<Route
Expand Down

0 comments on commit 568c28e

Please sign in to comment.