Skip to content

Commit

Permalink
Add component level theming (#11691)
Browse files Browse the repository at this point in the history
Part of [this
issue](https://github.com/Shopify/polaris-internal/issues/1513)

- Removed `html` from theme classes to remove global theme constraint
- Added `ThemeProvider` component
- Updated `useTheme` to be context aware of parent themes
- Updated `Portal` component to be context aware of parent themes
- Initialized a `dark-experimental` theme

Example
```tsx
function App() {
  return (
    /** Sets the default theme globally on the root HTML element (optional) */
    <AppProvider theme="light">
        {/* Sets the dark theme on an internal container element */}
        <ThemeProvider theme="dark-experimental">
          {/* ... */}
        </ThemeProvider>
    </AppProvider>
  )
}
```

## Tophat

Note: In web we will be restricting imports of the `ThemeProvider` to
only areas that Polaris approves

Make sure everything looks the same on spin. The `p-theme-light` class
should be added to the top html element

https://admin.web.web-xubz.sophie-schneider.us.spin.dev/store/shop1

---------

Co-authored-by: Sophie Schneider <thesophieschneider@gmail.com>
  • Loading branch information
aaronccasanova and sophschneider committed Mar 25, 2024
1 parent ca8eb99 commit 1e613de
Show file tree
Hide file tree
Showing 18 changed files with 393 additions and 57 deletions.
10 changes: 10 additions & 0 deletions .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
50 changes: 28 additions & 22 deletions polaris-react/src/components/AppProvider/AppProvider.tsx
Expand Up @@ -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,
Expand Down Expand Up @@ -170,27 +174,29 @@ export class AppProvider extends Component<AppProviderProps, State> {
const {intl, link} = this.state;

return (
<ThemeContext.Provider value={getTheme(themeName)}>
<FeaturesContext.Provider value={features}>
<I18nContext.Provider value={intl}>
<ScrollLockManagerContext.Provider value={this.scrollLockManager}>
<StickyManagerContext.Provider value={this.stickyManager}>
<LinkContext.Provider value={link}>
<MediaQueryProvider>
<PortalsManager>
<FocusManager>
<EphemeralPresenceManager>
{children}
</EphemeralPresenceManager>
</FocusManager>
</PortalsManager>
</MediaQueryProvider>
</LinkContext.Provider>
</StickyManagerContext.Provider>
</ScrollLockManagerContext.Provider>
</I18nContext.Provider>
</FeaturesContext.Provider>
</ThemeContext.Provider>
<ThemeNameContext.Provider value={themeName}>
<ThemeContext.Provider value={getTheme(themeName)}>
<FeaturesContext.Provider value={features}>
<I18nContext.Provider value={intl}>
<ScrollLockManagerContext.Provider value={this.scrollLockManager}>
<StickyManagerContext.Provider value={this.stickyManager}>
<LinkContext.Provider value={link}>
<MediaQueryProvider>
<PortalsManager>
<FocusManager>
<EphemeralPresenceManager>
{children}
</EphemeralPresenceManager>
</FocusManager>
</PortalsManager>
</MediaQueryProvider>
</LinkContext.Provider>
</StickyManagerContext.Provider>
</ScrollLockManagerContext.Provider>
</I18nContext.Provider>
</FeaturesContext.Provider>
</ThemeContext.Provider>
</ThemeNameContext.Provider>
);
}
}
Expand Up @@ -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<React.ContextType<typeof FrameContext>>;
type MediaQueryContextType = NonNullable<
Expand Down Expand Up @@ -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]);
Expand All @@ -76,29 +80,31 @@ export function PolarisTestProvider({

return (
<Wrapper>
<ThemeContext.Provider value={getTheme(theme)}>
<FeaturesContext.Provider value={features}>
<I18nContext.Provider value={intl}>
<ScrollLockManagerContext.Provider value={scrollLockManager}>
<StickyManagerContext.Provider value={stickyManager}>
<LinkContext.Provider value={link}>
<MediaQueryContext.Provider value={mergedMediaQuery}>
<PortalsManager>
<FocusManager>
<EphemeralPresenceManager>
<FrameContext.Provider value={mergedFrame}>
{children}
</FrameContext.Provider>
</EphemeralPresenceManager>
</FocusManager>
</PortalsManager>
</MediaQueryContext.Provider>
</LinkContext.Provider>
</StickyManagerContext.Provider>
</ScrollLockManagerContext.Provider>
</I18nContext.Provider>
</FeaturesContext.Provider>
</ThemeContext.Provider>
<ThemeNameContext.Provider value={themeName}>
<ThemeContext.Provider value={getTheme(themeName)}>
<FeaturesContext.Provider value={features}>
<I18nContext.Provider value={intl}>
<ScrollLockManagerContext.Provider value={scrollLockManager}>
<StickyManagerContext.Provider value={stickyManager}>
<LinkContext.Provider value={link}>
<MediaQueryContext.Provider value={mergedMediaQuery}>
<PortalsManager>
<FocusManager>
<EphemeralPresenceManager>
<FrameContext.Provider value={mergedFrame}>
{children}
</FrameContext.Provider>
</EphemeralPresenceManager>
</FocusManager>
</PortalsManager>
</MediaQueryContext.Provider>
</LinkContext.Provider>
</StickyManagerContext.Provider>
</ScrollLockManagerContext.Provider>
</I18nContext.Provider>
</FeaturesContext.Provider>
</ThemeContext.Provider>
</ThemeNameContext.Provider>
</Wrapper>
);
}
Expand Down
14 changes: 13 additions & 1 deletion 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;
Expand All @@ -14,6 +17,7 @@ export function Portal({
idPrefix = '',
onPortalCreated = noop,
}: PortalProps) {
const themeName = useThemeName();
const {container} = usePortalsManager();

const uniqueId = useId();
Expand All @@ -24,7 +28,15 @@ export function Portal({
}, [onPortalCreated]);

return container
? createPortal(<div data-portal-id={portalId}>{children}</div>, container)
? createPortal(
<ThemeProvider
theme={isThemeNameLocal(themeName) ? themeName : themeNameDefault}
data-portal-id={portalId}
>
{children}
</ThemeProvider>,
container,
)
: null;
}

Expand Down
@@ -0,0 +1,3 @@
.themeContainer {
color: var(--p-color-text);
}
@@ -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<typeof ThemeProvider>;

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 = (
<ThemeProvider theme="light">
<TopBar.Menu
activatorContent={
<ThemeProvider theme="dark-experimental">
<span>
<Icon source={HeartIcon} />
<Text as="span" visuallyHidden>
Light theme popover button
</Text>
</span>
</ThemeProvider>
}
open={isHeartMenuOpen}
onOpen={toggleIsHeartMenuOpen}
onClose={toggleIsHeartMenuOpen}
actions={[
{
items: [{content: 'Light theme popover'}],
},
]}
/>
</ThemeProvider>
);

const notificationsMenu = (
<ThemeProvider theme="dark-experimental">
<TopBar.Menu
activatorContent={
<span>
<Icon source={NotificationIcon} />
<Text as="span" visuallyHidden>
Dark theme popover button
</Text>
</span>
}
open={isNotificationsMenuOpen}
onOpen={toggleIsNotificationsMenuOpen}
onClose={toggleIsNotificationsMenuOpen}
actions={[
{
items: [{content: 'Dark theme popover'}],
},
]}
/>
</ThemeProvider>
);

return (
<Frame
topBar={
<TopBar
secondaryMenu={
<InlineStack>
{heartMenu}
{notificationsMenu}
</InlineStack>
}
/>
}
/>
);
}
56 changes: 56 additions & 0 deletions 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 (
<ThemeNameContext.Provider value={themeName}>
<ThemeContext.Provider value={getTheme(themeName)}>
<ThemeContainer
data-portal-id={props['data-portal-id']}
className={classNames(
createThemeClassName(themeName),
styles.themeContainer,
className,
)}
>
{children}
</ThemeContainer>
</ThemeContext.Provider>
</ThemeNameContext.Provider>
);
}
2 changes: 2 additions & 0 deletions polaris-react/src/components/ThemeProvider/index.ts
@@ -0,0 +1,2 @@
export type {ThemeProviderProps} from './ThemeProvider';
export {ThemeProvider, isThemeNameLocal} from './ThemeProvider';

0 comments on commit 1e613de

Please sign in to comment.