diff --git a/.changeset/purple-snails-do.md b/.changeset/purple-snails-do.md new file mode 100644 index 0000000000..a517b8e7c6 --- /dev/null +++ b/.changeset/purple-snails-do.md @@ -0,0 +1,6 @@ +--- +'visual-testing-app': minor +'@commercetools-uikit/design-system': minor +--- + +Restore theming support diff --git a/design-system/materials/internals/definition.yaml b/design-system/materials/internals/definition.yaml index 593ed0d93a..ee4d5e9c09 100644 --- a/design-system/materials/internals/definition.yaml +++ b/design-system/materials/internals/definition.yaml @@ -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' diff --git a/design-system/src/design-tokens.ts b/design-system/src/design-tokens.ts index 741e2fde0d..dde9a34964 100644 --- a/design-system/src/design-tokens.ts +++ b/design-system/src/design-tokens.ts @@ -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 = { diff --git a/design-system/src/theme-provider.tsx b/design-system/src/theme-provider.tsx index ad38dd35c6..a295835720 100644 --- a/design-system/src/theme-provider.tsx +++ b/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'; @@ -100,7 +103,6 @@ ThemeProvider.defaultProps = { type TUseThemeResult = { theme: ThemeName; - /** @deprecated */ themedValue: < Old extends string | ReactNode | undefined, New extends string | ReactNode | undefined @@ -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('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', }; }; diff --git a/design-system/src/theme-provider.visualroute.jsx b/design-system/src/theme-provider.visualroute.jsx index 17ffeae098..94cdab0226 100644 --- a/design-system/src/theme-provider.visualroute.jsx +++ b/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'; @@ -8,6 +9,7 @@ import { LocalDarkThemeProvider, LocalThemeProvider, } from '../../test/percy'; +import { ThemeProvider } from './theme-provider'; export const routePath = '/theme-provider'; @@ -15,9 +17,7 @@ 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 ( @@ -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 ( + <> + + + + +
+ + +
+ + ); +}; + export const component = () => ( + ); diff --git a/design-system/src/theme-provider.visualspec.js b/design-system/src/theme-provider.visualspec.js index d0a6078daa..ffe20f7edc 100644 --- a/design-system/src/theme-provider.visualspec.js +++ b/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 () => { @@ -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'); + }); +}); diff --git a/docs/.storybook/configs/contexts.js b/docs/.storybook/configs/contexts.js index 2f29b189a3..6d089b66be 100644 --- a/docs/.storybook/configs/contexts.js +++ b/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]; diff --git a/docs/.storybook/configs/theme-context.js b/docs/.storybook/configs/theme-context.js new file mode 100644 index 0000000000..0ddae0c9e5 --- /dev/null +++ b/docs/.storybook/configs/theme-context.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import { ThemeProvider } from '../../../design-system'; + +const ThemeWrapper = (props) => { + return ( + <> + + {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; diff --git a/docs/.storybook/decorators/theme-wrapper/theme-wrapper.js b/docs/.storybook/decorators/theme-wrapper/theme-wrapper.js index bc14b14301..33354e2ed2 100644 --- a/docs/.storybook/decorators/theme-wrapper/theme-wrapper.js +++ b/docs/.storybook/decorators/theme-wrapper/theme-wrapper.js @@ -4,7 +4,7 @@ import { ThemeProvider } from '../../../../design-system'; const ThemeWrapper = (storyFn) => { return ( <> - + {storyFn()} ); diff --git a/visual-testing-app/src/App.jsx b/visual-testing-app/src/App.jsx index 6e56adf2d6..b4e6c8aae1 100644 --- a/visual-testing-app/src/App.jsx +++ b/visual-testing-app/src/App.jsx @@ -29,7 +29,7 @@ const allSortedComponents = Object.keys(allUniqueRouteComponents) const App = () => ( <> - +