Skip to content

Commit

Permalink
Add multi-theme support to Polaris (#10290)
Browse files Browse the repository at this point in the history
This PR consolidates the integration of three feature branches that
collectively introduce a new base/variant theme architecture to Shopify
Polaris. The merged PRs introduce multi-theme support, updated build
artifacts, and a new `useTheme` hook for runtime Polaris tokens access.
Below, you will find a brief overview of each PR, with links to the
original PRs for a more detailed review.

## [Add multi-theme support to Polaris
tokens](#10103)

This PR introduces multi-theme support to `@shopify/polaris-tokens` by
implementing a base/variant architecture. The base theme serves as the
foundation for multiple variant themes, which extend the base theme and
are the only themes exposed to consumers. Simplifying system changes by
flattening the relationship between themes is the primary advantage of
this pattern. The PR also includes implementation details like directory
structure, stylesheet generation, and variant theme functionality.

**Example `styles.scss` updates**

```scss
:root {
  /* Default variant theme custom properties (light) */
}

html.Polaris-Summer-Editions-2023 {
  /* Variant theme custom properties */
  /*
     Note: The above selector is special
     cased for backward compatibility. See
     below for updated theme selector structure.
  */
}

html.p-theme-light-high-contrast {
  /* Variant theme custom properties */
  /*
     Note: Variant theme selectors
     contain a subset of custom properties
     overriding the base theme
  */
}

html.p-theme-dark {/* ... */}
html.p-theme-dark-high-contrast {/* ... */}
html.p-theme-dim {/* ... */}
```

## [Add multi-theme build artifacts to Polaris
tokens](#10250)

This PR is a rework of #10153, redefining how the `themes` are accessed
from `@shopify/polaris-tokens`. It introduces changes on how asset
builder functions are written and replaces reference to `themes.light`
with a `themeDefault` alias.

```ts
import {themes} from '@shopify/polaris-tokens';

themes['Polaris-Summer-Editions-2023'].color['color-bg']
```

> Note: Future releases can reduce the bundle size impact of including
all themes on one object and enable more flexibility (for example with a
`createTheme` util that deep merges variant theme partials). However,
that is not needed at this time and can be introduced in a minor
release.

## [Add `useTheme` hook for runtime Polaris tokens
access](#10252)

This PR is a rework of #10229, brings updates to the `AppProvider` by
incorporating a new `ThemeContext.Provider` and an associated `useTheme`
hook.

```tsx
import {useTheme} from '@shopify/polaris';

function App() {
  const theme = useTheme()

  theme.color['color-bg'] // '#123'
}
```

---------

Co-authored-by: Laura Griffee <laura@mailzone.com>
  • Loading branch information
aaronccasanova and lgriffee committed Sep 7, 2023
1 parent 88b3ea3 commit 5939b49
Show file tree
Hide file tree
Showing 28 changed files with 585 additions and 343 deletions.
8 changes: 8 additions & 0 deletions .changeset/cool-insects-wonder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@shopify/polaris': minor
'@shopify/polaris-tokens': minor
---

- Added multi-theme support
- Added multi-theme build artifacts
- Added multi-theme runtime access
49 changes: 30 additions & 19 deletions polaris-react/src/components/AppProvider/AppProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import React, {Component} from 'react';
import type {ThemeName} from '@shopify/polaris-tokens';
import {themeNameDefault} from '@shopify/polaris-tokens';

import {EphemeralPresenceManager} from '../EphemeralPresenceManager';
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 {
ScrollLockManager,
ScrollLockManagerContext,
Expand Down Expand Up @@ -153,32 +156,40 @@ export class AppProvider extends Component<AppProviderProps, State> {
};
};

getThemeName = (): ThemeName =>
this.getFeatures().polarisSummerEditions2023
? 'Polaris-Summer-Editions-2023'
: themeNameDefault;

render() {
const {children} = this.props;
const features = this.getFeatures();
const themeName = this.getThemeName();

const {intl, link} = this.state;

return (
<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 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>
);
}
}
1 change: 1 addition & 0 deletions polaris-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ export {
export {ScrollLockManagerContext as _SECRET_INTERNAL_SCROLL_LOCK_MANAGER_CONTEXT} from './utilities/scroll-lock-manager';
export {WithinContentContext as _SECRET_INTERNAL_WITHIN_CONTENT_CONTEXT} from './utilities/within-content-context';
export {useEventListener} from './utilities/use-event-listener';
export {useTheme} from './utilities/use-theme';
export {useIndexResourceState} from './utilities/use-index-resource-state';
export {
useRowHovered as useIndexTableRowHovered,
Expand Down
23 changes: 23 additions & 0 deletions polaris-react/src/utilities/use-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {createContext, useContext} from 'react';
import type {ThemeName} from '@shopify/polaris-tokens';
import {themes} from '@shopify/polaris-tokens';

export type Theme = typeof themes[ThemeName];

export function getTheme(themeName: ThemeName): Theme {
return themes[themeName];
}

export const ThemeContext = createContext<Theme | null>(null);

export function useTheme() {
const theme = useContext(ThemeContext);

if (!theme) {
throw new Error(
'No theme was provided. Your application must be wrapped in an <AppProvider> component. See https://polaris.shopify.com/components/app-provider for implementation instructions.',
);
}

return theme;
}
5 changes: 4 additions & 1 deletion polaris-tokens/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,8 @@
],
"files": [
"dist"
]
],
"dependencies": {
"deepmerge": "^4.3.1"
}
}
12 changes: 5 additions & 7 deletions polaris-tokens/scripts/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import {metadata} from '../src';

import {toTokenValues} from './toTokenValues';
import {toJSON} from './toJSON';
import {toMediaConditions} from './toMediaConditions';
import {toStyleSheet} from './toStyleSheet';
import {toValues} from './toValues';

(async () => {
await Promise.all([
toTokenValues(metadata),
toJSON(metadata),
toMediaConditions(metadata.breakpoints),
toStyleSheet(metadata),
toJSON(),
toMediaConditions(),
toStyleSheet(),
toValues(),
]);
})();
22 changes: 11 additions & 11 deletions polaris-tokens/scripts/toJSON.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import fs from 'fs';
import path from 'path';

import type {Metadata, MetadataGroup} from '../src';
import {metaThemeDefault} from '../src/themes';

const outputDir = path.join(__dirname, '../dist/json');

export async function toJSON(metadata: Metadata) {
if (!fs.existsSync(outputDir)) {
await fs.promises.mkdir(outputDir, {recursive: true});
}
export async function toJSON() {
await fs.promises.mkdir(outputDir, {recursive: true}).catch((error) => {
if (error.code !== 'EEXIST') {
throw error;
}
});

for (const entry of Object.entries(metadata)) {
const [tokenGroupName, tokenGroup] = entry as [
keyof Metadata,
MetadataGroup,
];
for (const [tokenGroupName, metaTokenGroup] of Object.entries(
metaThemeDefault,
)) {
const filePath = path.join(outputDir, `${tokenGroupName}.json`);

await fs.promises.writeFile(filePath, JSON.stringify(tokenGroup));
await fs.promises.writeFile(filePath, JSON.stringify(metaTokenGroup));
}
}
21 changes: 12 additions & 9 deletions polaris-tokens/scripts/toMediaConditions.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import fs from 'fs';
import path from 'path';

import {getMediaConditions, removeMetadata} from '../src';
import type {MetaBreakpointsTokenGroup} from '../src';
import {getMediaConditions} from '../src';
import {metaThemeDefault} from '../src/themes';
import {extractMetaTokenGroupValues} from '../src/themes/utils';

const scssOutputDir = path.join(__dirname, '../dist/scss');
const scssOutputPath = path.join(scssOutputDir, 'media-queries.scss');

export async function toMediaConditions(
breakpoints: MetaBreakpointsTokenGroup,
) {
if (!fs.existsSync(scssOutputDir)) {
await fs.promises.mkdir(scssOutputDir, {recursive: true});
}
export async function toMediaConditions() {
await fs.promises.mkdir(scssOutputDir, {recursive: true}).catch((error) => {
if (error.code !== 'EEXIST') {
throw error;
}
});

const mediaConditionEntries = Object.entries(
getMediaConditions(removeMetadata(breakpoints)),
getMediaConditions(
extractMetaTokenGroupValues(metaThemeDefault.breakpoints),
),
);

const styles = mediaConditionEntries
Expand Down
125 changes: 56 additions & 69 deletions polaris-tokens/scripts/toStyleSheet.ts
Original file line number Diff line number Diff line change
@@ -1,91 +1,78 @@
import fs from 'fs';
import path from 'path';

import type {Metadata, MetadataGroup} from '../src';
import type {MetadataBase} from '../src/types';
import type {MetaThemeShape, MetaTokenGroupShape} from '../src/themes/types';
import {metaThemeVariantPartials, metaThemeDefault} from '../src/themes';
import {themeNameDefault} from '../src/themes/constants';
import {createThemeSelector} from '../src/themes/utils';
import {createVar} from '../src/utilities';
import type {Entries} from '../src/types';

const cssOutputDir = path.join(__dirname, '../dist/css');
const sassOutputDir = path.join(__dirname, '../dist/scss');
const scssOutputDir = path.join(__dirname, '../dist/scss');
const cssOutputPath = path.join(cssOutputDir, 'styles.css');
const sassOutputPath = path.join(sassOutputDir, 'styles.scss');
const scssOutputPath = path.join(scssOutputDir, 'styles.scss');

/**
* Creates static CSS custom properties.
* Note: These values don't vary by color-scheme.
*/
export function getStaticCustomProperties(metadata: Metadata) {
return Object.entries(metadata)
.map(([_, tokenGroup]) => getCustomProperties(tokenGroup))
/** Creates CSS declarations from a base or variant partial theme. */
export function getMetaThemeDecls(metaTheme: MetaThemeShape) {
return Object.values(metaTheme)
.map((metaTokenGroup) => getMetaTokenGroupDecls(metaTokenGroup))
.join('');
}

/**
* Creates static CSS custom properties overrides.
* Note: These values don't vary by color-scheme.
*/
export function getStaticCustomPropertiesExperimental(metadata: MetadataBase) {
return Object.entries(metadata)
.map(([_, tokenGroup]) =>
getCustomProperties(
Object.fromEntries(
Object.entries(tokenGroup)
// Only include tokens with `valueExperimental` prop
.filter(([_, metadataProperties]) =>
Boolean(metadataProperties.valueExperimental),
)
// Move `valueExperimental` to `value` position
.map(([tokenName, metadataProperties]) => [
tokenName,
{value: metadataProperties.valueExperimental!},
]),
),
),
/** Creates CSS declarations from a token group. */
export function getMetaTokenGroupDecls(metaTokenGroup: MetaTokenGroupShape) {
return Object.entries(metaTokenGroup)
.map(([tokenName, {value}]) =>
tokenName.startsWith('motion-keyframes')
? `${createVar(tokenName)}:p-${tokenName};`
: `${createVar(tokenName)}:${value};`,
)
.join('');
}

/**
* Creates CSS custom properties for a given metadata object.
*/
export function getCustomProperties(tokenGroup: MetadataGroup) {
return Object.entries(tokenGroup)
.map(([token, {value}]) =>
token.startsWith('motion-keyframes') || token.startsWith('keyframes')
? `--p-${token}:p-${token};`
: `--p-${token}:${value};`,
)
.join('');
}

/**
* Concatenates the `keyframes` token-group into a single string.
*/
export function getKeyframes(motion: MetadataGroup) {
/** Creates `@keyframes` rules for `motion-keyframes-*` tokens. */
export function getKeyframes(motion: MetaTokenGroupShape) {
return Object.entries(motion)
.filter(
([token]) =>
token.startsWith('motion-keyframes') || token.startsWith('keyframes'),
)
.map(([token, {value}]) => `@keyframes p-${token}${value}`)
.filter(([tokenName]) => tokenName.startsWith('motion-keyframes'))
.map(([tokenName, {value}]) => `@keyframes p-${tokenName}${value}`)
.join('');
}

export async function toStyleSheet(metadata: Metadata) {
if (!fs.existsSync(cssOutputDir)) {
await fs.promises.mkdir(cssOutputDir, {recursive: true});
}
if (!fs.existsSync(sassOutputDir)) {
await fs.promises.mkdir(sassOutputDir, {recursive: true});
}
export async function toStyleSheet() {
await fs.promises.mkdir(cssOutputDir, {recursive: true}).catch((error) => {
if (error.code !== 'EEXIST') {
throw error;
}
});

await fs.promises.mkdir(scssOutputDir, {recursive: true}).catch((error) => {
if (error.code !== 'EEXIST') {
throw error;
}
});

const metaThemeVariantPartialsEntries = Object.entries(
metaThemeVariantPartials,
).filter(([themeName]) => themeName !== themeNameDefault) as Entries<
Omit<typeof metaThemeVariantPartials, typeof themeNameDefault>
>;

const styles = `
:root{color-scheme:light;${getStaticCustomProperties(metadata)}}
html.Polaris-Summer-Editions-2023{${getStaticCustomPropertiesExperimental(
metadata,
)}}
${getKeyframes(metadata.motion)}
`;
const styles = [
`:root{color-scheme:light;${getMetaThemeDecls(metaThemeDefault)}}`,
metaThemeVariantPartialsEntries.map(
([themeName, metaThemeVariantPartial]) =>
`${createThemeSelector(themeName)}{${getMetaThemeDecls(
metaThemeVariantPartial,
)}}`,
),
getKeyframes(metaThemeDefault.motion),
// Newline terminator
'',
]
.flat()
.join('\n');

await fs.promises.writeFile(cssOutputPath, styles);
await fs.promises.writeFile(sassOutputPath, styles);
await fs.promises.writeFile(scssOutputPath, styles);
}
Loading

0 comments on commit 5939b49

Please sign in to comment.