diff --git a/.changeset/mighty-hounds-boil.md b/.changeset/mighty-hounds-boil.md new file mode 100644 index 00000000000..6fc4e0583f3 --- /dev/null +++ b/.changeset/mighty-hounds-boil.md @@ -0,0 +1,10 @@ +--- +'@shopify/polaris': minor +'@shopify/polaris-tokens': minor +--- + +- Added `ThemeProvider` component +- Removed `html` from theme classes to remove global theme constraint +- Updated `useTheme` to be context aware of parent themes +- Updated `Portal` component to be context aware of parent themes +- Initialized a `dark-experimental` theme diff --git a/polaris-react/src/components/AppProvider/AppProvider.tsx b/polaris-react/src/components/AppProvider/AppProvider.tsx index a9c1abd3d60..7020b285235 100644 --- a/polaris-react/src/components/AppProvider/AppProvider.tsx +++ b/polaris-react/src/components/AppProvider/AppProvider.tsx @@ -11,7 +11,11 @@ import {MediaQueryProvider} from '../MediaQueryProvider'; import {FocusManager} from '../FocusManager'; import {PortalsManager} from '../PortalsManager'; import {I18n, I18nContext} from '../../utilities/i18n'; -import {ThemeContext, getTheme} from '../../utilities/use-theme'; +import { + ThemeNameContext, + ThemeContext, + getTheme, +} from '../../utilities/use-theme'; import { ScrollLockManager, ScrollLockManagerContext, @@ -170,27 +174,29 @@ export class AppProvider extends Component { const {intl, link} = this.state; return ( - - - - - - - - - - - {children} - - - - - - - - - - + + + + + + + + + + + + {children} + + + + + + + + + + + ); } } diff --git a/polaris-react/src/components/PolarisTestProvider/PolarisTestProvider.tsx b/polaris-react/src/components/PolarisTestProvider/PolarisTestProvider.tsx index f232423d7c5..1b4d7d56fd0 100644 --- a/polaris-react/src/components/PolarisTestProvider/PolarisTestProvider.tsx +++ b/polaris-react/src/components/PolarisTestProvider/PolarisTestProvider.tsx @@ -21,7 +21,11 @@ import type {LinkLikeComponent} from '../../utilities/link'; import {FeaturesContext} from '../../utilities/features'; import type {FeaturesConfig} from '../../utilities/features'; import {EphemeralPresenceManager} from '../EphemeralPresenceManager'; -import {ThemeContext, getTheme} from '../../utilities/use-theme'; +import { + ThemeNameContext, + ThemeContext, + getTheme, +} from '../../utilities/use-theme'; type FrameContextType = NonNullable>; type MediaQueryContextType = NonNullable< @@ -62,7 +66,7 @@ export function PolarisTestProvider({ mediaQuery, features, frame, - theme = themeNameDefault, + theme: themeName = themeNameDefault, }: PolarisTestProviderProps) { const Wrapper = strict ? StrictMode : Fragment; const intl = useMemo(() => new I18n(i18n || {}), [i18n]); @@ -76,29 +80,31 @@ export function PolarisTestProvider({ return ( - - - - - - - - - - - - {children} - - - - - - - - - - - + + + + + + + + + + + + + {children} + + + + + + + + + + + + ); } diff --git a/polaris-react/src/components/Portal/Portal.tsx b/polaris-react/src/components/Portal/Portal.tsx index cb3fd0da96b..ce13272b762 100644 --- a/polaris-react/src/components/Portal/Portal.tsx +++ b/polaris-react/src/components/Portal/Portal.tsx @@ -1,7 +1,10 @@ +import {themeNameDefault} from '@shopify/polaris-tokens'; import React, {useEffect, useId} from 'react'; import {createPortal} from 'react-dom'; import {usePortalsManager} from '../../utilities/portals'; +import {useThemeName} from '../../utilities/use-theme'; +import {ThemeProvider, isThemeNameLocal} from '../ThemeProvider'; export interface PortalProps { children?: React.ReactNode; @@ -14,6 +17,7 @@ export function Portal({ idPrefix = '', onPortalCreated = noop, }: PortalProps) { + const themeName = useThemeName(); const {container} = usePortalsManager(); const uniqueId = useId(); @@ -24,7 +28,15 @@ export function Portal({ }, [onPortalCreated]); return container - ? createPortal(
{children}
, container) + ? createPortal( + + {children} + , + container, + ) : null; } diff --git a/polaris-react/src/components/ThemeProvider/ThemeProvider.module.css b/polaris-react/src/components/ThemeProvider/ThemeProvider.module.css new file mode 100644 index 00000000000..25cd470be8d --- /dev/null +++ b/polaris-react/src/components/ThemeProvider/ThemeProvider.module.css @@ -0,0 +1,3 @@ +.themeContainer { + color: var(--p-color-text); +} diff --git a/polaris-react/src/components/ThemeProvider/ThemeProvider.stories.tsx b/polaris-react/src/components/ThemeProvider/ThemeProvider.stories.tsx new file mode 100644 index 00000000000..facf8c75809 --- /dev/null +++ b/polaris-react/src/components/ThemeProvider/ThemeProvider.stories.tsx @@ -0,0 +1,97 @@ +import React, {useCallback, useState} from 'react'; +import type {ComponentMeta} from '@storybook/react'; +import { + Frame, + Icon, + TopBar, + Text, + ThemeProvider, + InlineStack, +} from '@shopify/polaris'; +import {HeartIcon, NotificationIcon} from '@shopify/polaris-icons'; + +export default { + component: ThemeProvider, +} as ComponentMeta; + +export function Default() { + const [isHeartMenuOpen, setIsHeartMenuOpen] = useState(true); + + const toggleIsHeartMenuOpen = useCallback( + () => setIsHeartMenuOpen((isHeartMenuOpen) => !isHeartMenuOpen), + [], + ); + + const [isNotificationsMenuOpen, setIsNotificationsMenuOpen] = useState(false); + + const toggleIsNotificationsMenuOpen = useCallback( + () => + setIsNotificationsMenuOpen( + (isNotificationsMenuOpen) => !isNotificationsMenuOpen, + ), + [], + ); + + const heartMenu = ( + + + + + + Light theme popover button + + + + } + open={isHeartMenuOpen} + onOpen={toggleIsHeartMenuOpen} + onClose={toggleIsHeartMenuOpen} + actions={[ + { + items: [{content: 'Light theme popover'}], + }, + ]} + /> + + ); + + const notificationsMenu = ( + + + + + Dark theme popover button + + + } + open={isNotificationsMenuOpen} + onOpen={toggleIsNotificationsMenuOpen} + onClose={toggleIsNotificationsMenuOpen} + actions={[ + { + items: [{content: 'Dark theme popover'}], + }, + ]} + /> + + ); + + return ( + + {heartMenu} + {notificationsMenu} + + } + /> + } + /> + ); +} diff --git a/polaris-react/src/components/ThemeProvider/ThemeProvider.tsx b/polaris-react/src/components/ThemeProvider/ThemeProvider.tsx new file mode 100644 index 00000000000..2284b403942 --- /dev/null +++ b/polaris-react/src/components/ThemeProvider/ThemeProvider.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import {themeNameDefault, createThemeClassName} from '@shopify/polaris-tokens'; + +import { + ThemeContext, + getTheme, + ThemeNameContext, +} from '../../utilities/use-theme'; +import {classNames} from '../../utilities/css'; + +import styles from './ThemeProvider.module.css'; + +/** + * Allowlist of local themes + * TODO: Replace `as const` with `satisfies ThemeName[]` + */ +export const themeNamesLocal = ['light', 'dark-experimental'] as const; + +type ThemeNameLocal = typeof themeNamesLocal[number]; + +export const isThemeNameLocal = (name: string): name is ThemeNameLocal => + themeNamesLocal.includes(name as any); + +export interface ThemeProviderProps { + as?: keyof React.ReactHTML; + children: React.ReactNode; + className?: string; + theme?: ThemeNameLocal; + 'data-portal-id'?: string; +} + +export function ThemeProvider(props: ThemeProviderProps) { + const { + as: ThemeContainer = 'div', + children, + className, + theme: themeName = themeNameDefault, + } = props; + + return ( + + + + {children} + + + + ); +} diff --git a/polaris-react/src/components/ThemeProvider/index.ts b/polaris-react/src/components/ThemeProvider/index.ts new file mode 100644 index 00000000000..39137e75160 --- /dev/null +++ b/polaris-react/src/components/ThemeProvider/index.ts @@ -0,0 +1,2 @@ +export type {ThemeProviderProps} from './ThemeProvider'; +export {ThemeProvider, isThemeNameLocal} from './ThemeProvider'; diff --git a/polaris-react/src/components/ThemeProvider/tests/ThemeProvider.test.tsx b/polaris-react/src/components/ThemeProvider/tests/ThemeProvider.test.tsx new file mode 100644 index 00000000000..a9024d8db65 --- /dev/null +++ b/polaris-react/src/components/ThemeProvider/tests/ThemeProvider.test.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import {mountWithApp} from 'tests/utilities'; +import {themeNameDefault} from '@shopify/polaris-tokens'; +import type {ThemeName} from '@shopify/polaris-tokens'; + +import {ThemeProvider} from '../ThemeProvider'; +import {useThemeName} from '../../../utilities/use-theme'; + +const LIGHT_THEME: ThemeName = 'light'; +const DARK_THEME: ThemeName = 'dark-experimental'; + +describe('', () => { + const ThemeNameText = () => { + const themeName = useThemeName(); + + return {themeName}; + }; + + it('uses default theme when no component level ThemeProviders', () => { + const app = mountWithApp(); + expect(app).toContainReactText(themeNameDefault); + }); + + it('uses light theme when wrapped in a light theme provider', () => { + const app = mountWithApp( + + + , + ); + expect(app).toContainReactText(LIGHT_THEME); + }); + + it('uses dark theme when wrapped in a dark theme provider', () => { + const app = mountWithApp( + + + , + ); + expect(app).toContainReactText(DARK_THEME); + }); + + it('nests a dark theme within a light theme', () => { + const app = mountWithApp( + + + + + + , + ); + + const themes = app.findAll(ThemeNameText); + expect(themes[0]).toContainReactText(LIGHT_THEME); + expect(themes[1]).toContainReactText(DARK_THEME); + }); + + it('nests a light theme within a dark theme', () => { + const app = mountWithApp( + + + + + + , + ); + + const themes = app.findAll(ThemeNameText); + expect(themes[0]).toContainReactText(DARK_THEME); + expect(themes[1]).toContainReactText(LIGHT_THEME); + }); +}); diff --git a/polaris-react/src/index.ts b/polaris-react/src/index.ts index c9b223f2d72..a1c0a70b5ab 100644 --- a/polaris-react/src/index.ts +++ b/polaris-react/src/index.ts @@ -368,6 +368,9 @@ export type {TextContainerProps} from './components/TextContainer'; export {TextField} from './components/TextField'; export type {TextFieldProps} from './components/TextField'; +export type {ThemeProviderProps} from './components/ThemeProvider/ThemeProvider'; +export {ThemeProvider} from './components/ThemeProvider/ThemeProvider'; + export {Thumbnail} from './components/Thumbnail'; export type {ThumbnailProps} from './components/Thumbnail'; diff --git a/polaris-react/src/utilities/use-theme.ts b/polaris-react/src/utilities/use-theme.ts index 39c7788036d..4bf0ef8a595 100644 --- a/polaris-react/src/utilities/use-theme.ts +++ b/polaris-react/src/utilities/use-theme.ts @@ -2,24 +2,37 @@ import {createContext, useContext} from 'react'; import type {ThemeName, Theme} from '@shopify/polaris-tokens'; import {themes} from '@shopify/polaris-tokens'; +export const ThemeContext = createContext(null); +export const ThemeNameContext = createContext(null); + export function getTheme(themeName: ThemeName): Theme { return themes[themeName]; } -export const ThemeContext = createContext(null); - export function useTheme() { const theme = useContext(ThemeContext); if (!theme) { throw new Error( - 'No theme was provided. Your application must be wrapped in an component. See https://polaris.shopify.com/components/app-provider for implementation instructions.', + 'No theme was provided. Your application must be wrapped in an or component. See https://polaris.shopify.com/components/app-provider for implementation instructions.', ); } return theme; } +export function useThemeName() { + const themeName = useContext(ThemeNameContext); + + if (!themeName) { + throw new Error( + 'No themeName was provided. Your application must be wrapped in an or component. See https://polaris.shopify.com/components/app-provider for implementation instructions.', + ); + } + + return themeName; +} + export function UseTheme(props: {children(theme: Theme): JSX.Element}) { const theme = useTheme(); diff --git a/polaris-tokens/scripts/toStyleSheet.ts b/polaris-tokens/scripts/toStyleSheet.ts index 9c0a07a3443..f78e5e8a0b9 100644 --- a/polaris-tokens/scripts/toStyleSheet.ts +++ b/polaris-tokens/scripts/toStyleSheet.ts @@ -33,9 +33,15 @@ export function getMetaTokenGroupDecls(metaTokenGroup: MetaTokenGroupShape) { MetaTokenGroupShape[string], ]; - return tokenName.startsWith('motion-keyframes') - ? `${createVarName(tokenName)}:p-${tokenName};` - : `${createVarName(tokenName)}:${value};`; + if (tokenName.startsWith('color-scheme')) { + return `color-scheme:${value};`; + } + + if (tokenName.startsWith('motion-keyframes')) { + return `${createVarName(tokenName)}:p-${tokenName};`; + } + + return `${createVarName(tokenName)}:${value};`; }) .join(''); } @@ -68,7 +74,7 @@ export async function toStyleSheet() { const styles = [ [ `:root,${createThemeSelector(themeNameDefault)}`, - `{color-scheme:light;${getMetaThemeDecls(metaThemeDefault)}}`, + `{${getMetaThemeDecls(metaThemeDefault)}}`, ].join(''), metaThemePartialsEntries.map( ([themeName, metaThemePartial]) => diff --git a/polaris-tokens/src/themes/base/color.ts b/polaris-tokens/src/themes/base/color.ts index ba80f1ef496..31ca3e4ae4d 100644 --- a/polaris-tokens/src/themes/base/color.ts +++ b/polaris-tokens/src/themes/base/color.ts @@ -235,6 +235,7 @@ export type ColorTextAlias = | 'video-thumbnail-play-button-text-on-bg-fill'; export type ColorTokenName = + | 'color-scheme' | `color-${ColorBackgroundAlias}` | `color-${ColorBorderAlias}` | `color-${ColorIconAlias}` @@ -247,6 +248,9 @@ export type ColorTokenGroup = { export const color: { [TokenName in ColorTokenName]: MetaTokenProperties; } = { + 'color-scheme': { + value: 'light', + }, 'color-bg': { value: colors.gray[6], description: 'The default background color of the admin.', diff --git a/polaris-tokens/src/themes/constants.ts b/polaris-tokens/src/themes/constants.ts index 56e5128304e..79186da2d0a 100644 --- a/polaris-tokens/src/themes/constants.ts +++ b/polaris-tokens/src/themes/constants.ts @@ -5,4 +5,5 @@ export const themeNames = [ themeNameLight, 'light-mobile', 'light-high-contrast-experimental', + 'dark-experimental', ] as const; diff --git a/polaris-tokens/src/themes/dark.ts b/polaris-tokens/src/themes/dark.ts new file mode 100644 index 00000000000..e7d95b38e89 --- /dev/null +++ b/polaris-tokens/src/themes/dark.ts @@ -0,0 +1,20 @@ +import * as colors from '../colors'; + +import {createMetaTheme, createMetaThemePartial} from './utils'; + +export const metaThemeDarkPartial = createMetaThemePartial({ + color: { + 'color-scheme': {value: 'dark'}, + 'color-bg': {value: colors.gray[16]}, + 'color-bg-surface': {value: colors.gray[15]}, + 'color-bg-fill': {value: colors.gray[15]}, + 'color-icon': {value: colors.gray[8]}, + 'color-icon-secondary': {value: colors.gray[12]}, + 'color-text': {value: colors.gray[8]}, + 'color-text-secondary': {value: colors.gray[11]}, + 'color-bg-surface-secondary-active': {value: colors.gray[13]}, + 'color-bg-surface-secondary-hover': {value: colors.gray[14]}, + }, +}); + +export const metaThemeDark = createMetaTheme(metaThemeDarkPartial); diff --git a/polaris-tokens/src/themes/index.ts b/polaris-tokens/src/themes/index.ts index c6bec6b0e57..d1346fe6664 100644 --- a/polaris-tokens/src/themes/index.ts +++ b/polaris-tokens/src/themes/index.ts @@ -10,6 +10,7 @@ import { metaThemeLightMobile, metaThemeLightMobilePartial, } from './light-mobile'; +import {metaThemeDark, metaThemeDarkPartial} from './dark'; export {createMetaTheme} from './utils'; @@ -17,12 +18,14 @@ export const metaThemes: MetaThemes = { light: metaThemeLight, 'light-mobile': metaThemeLightMobile, 'light-high-contrast-experimental': metaThemeLightHighContrast, + 'dark-experimental': metaThemeDark, }; export const metaThemePartials: MetaThemePartials = { light: metaThemeLightPartial, 'light-mobile': metaThemeLightMobilePartial, 'light-high-contrast-experimental': metaThemeLightHighContrastPartial, + 'dark-experimental': metaThemeDarkPartial, }; export const metaThemeDefaultPartial = metaThemePartials[themeNameDefault]; diff --git a/polaris-tokens/src/themes/utils.ts b/polaris-tokens/src/themes/utils.ts index 2ad2111c393..77a6ebd6617 100644 --- a/polaris-tokens/src/themes/utils.ts +++ b/polaris-tokens/src/themes/utils.ts @@ -57,7 +57,7 @@ export function createThemeClassName(themeName: ThemeName) { } export function createThemeSelector(themeName: ThemeName) { - return `html.${createThemeClassName(themeName)}`; + return `.${createThemeClassName(themeName)}`; } export function extractMetaTokenGroupValues( diff --git a/polaris-tokens/tests/toStyleSheet.test.js b/polaris-tokens/tests/toStyleSheet.test.js index 1bd5d014f32..4db7eba8d4b 100644 --- a/polaris-tokens/tests/toStyleSheet.test.js +++ b/polaris-tokens/tests/toStyleSheet.test.js @@ -27,8 +27,22 @@ const mockMotionTokenGroup = { value: 'valueD', }, }; + +const mockColorTokenGroup = { + 'color-scheme': { + value: 'light', + }, + 'color-token-1': { + value: 'valueA', + }, + 'color-token-2': { + value: 'valueB', + }, +}; + const mockTheme = { tokenGroupName: mockTokenGroup, + color: mockColorTokenGroup, motion: mockMotionTokenGroup, }; @@ -38,7 +52,10 @@ const expectedTokenGroupDecls = const expectedMotionTokenGroupDecls = '--p-motion-token-1:valueA;--p-motion-token-2:valueB;--p-motion-keyframes-token-1:p-motion-keyframes-token-1;--p-motion-keyframes-token-2:p-motion-keyframes-token-2;'; -const expectedThemeDecls = `${expectedTokenGroupDecls}${expectedMotionTokenGroupDecls}`; +const expectedColorTokenGroupDecls = + 'color-scheme:light;--p-color-token-1:valueA;--p-color-token-2:valueB;'; + +const expectedThemeDecls = `${expectedTokenGroupDecls}${expectedColorTokenGroupDecls}${expectedMotionTokenGroupDecls}`; const expectedMotionKeyframes = '@keyframes p-motion-keyframes-token-1valueC@keyframes p-motion-keyframes-token-2valueD'; @@ -55,6 +72,12 @@ describe('getMetaTokenGroupDecls', () => { expect(tokenGroupDecls).toBe(expectedMotionTokenGroupDecls); }); + + it('creates a string of CSS declarations from color tokens', () => { + const tokenGroupDecls = getMetaTokenGroupDecls(mockColorTokenGroup); + + expect(tokenGroupDecls).toBe(expectedColorTokenGroupDecls); + }); }); describe('getKeyframes', () => {