Skip to content

Commit 09eec26

Browse files
authored
Refactor "splitTheme" Decorator (#13422)
Splits up the single "splitTheme" decorator function into multiple components, functions and types, each with documentation, to make it easier to understand. Also removes the `theme` arg for two reasons: 1. Similar behaviour is now supported by the `colourSchemeBackground` and `colourSchemeTextColour` parameters. 2. Configuring addons and decorators is normally achieved using parameters. The `format` arg is an unusual case, as we want to replace the story-level `format` arg with the `formats` parameter. In general, however, using an `arg` carries the risk that we will unintentionally override any arg in a component that has an identically-named prop. An example of this is the `EmailSignup` component, which has a `theme` prop that would be overridden by this decorator. As a result of this removal, updated the `ShareButton` stories to use the parameters mentioned in 1).
1 parent 75c91f0 commit 09eec26

File tree

2 files changed

+237
-117
lines changed

2 files changed

+237
-117
lines changed

dotcom-rendering/.storybook/decorators/splitThemeDecorator.tsx

Lines changed: 216 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -15,41 +15,22 @@ import {
1515
} from '@guardian/source/foundations';
1616
import { Decorator } from '@storybook/react';
1717
import { storybookPaletteDeclarations as paletteDeclarations } from '../mocks/paletteDeclarations';
18+
import type { ReactNode } from 'react';
1819

1920
interface Orientation {
2021
orientation?: 'horizontal' | 'vertical';
2122
}
2223

23-
const headerCss = css`
24-
${textSansBold20};
25-
text-align: center;
26-
padding: ${space[2]}px;
27-
`;
28-
29-
const styles = css`
30-
display: grid;
31-
max-width: 100%;
32-
`;
24+
/**
25+
* The `splitTheme` decorator displays a story simultaneously in both light and
26+
* dark mode.
27+
*/
28+
type ColourScheme = 'light' | 'dark';
3329

34-
const FormatHeading = ({ format }: { format: ArticleFormat }) => (
35-
<h3
36-
css={css`
37-
${textSans17};
38-
text-align: center;
39-
padding: ${space[1]}px;
40-
opacity: 0.75;
41-
`}
42-
>
43-
{[
44-
`Display: ${ArticleDisplay[format.display]}`,
45-
`Design: ${ArticleDesign[format.design]}`,
46-
`Theme: ${Pillar[format.theme] || ArticleSpecial[format.theme]}`,
47-
]
48-
.map((line) => line.replaceAll(' ', ' ')) // non-breaking spaces
49-
.join(', ')}
50-
</h3>
51-
);
52-
// ----- Decorators ----- //
30+
/**
31+
* The second argument to a Storybook decorator is the story's context.
32+
*/
33+
type Context = Parameters<Decorator>[1];
5334

5435
/** A list of the most typical formats */
5536
export const defaultFormats = [
@@ -126,9 +107,197 @@ export const defaultFormats = [
126107
] as const satisfies readonly ArticleFormat[];
127108

128109
/**
129-
* Creates storybook decorator used to render a component twice
130-
* Once in light mode, once in dark mode
131-
* Using a split screen
110+
* Derives the background colour for a story based on the colour scheme set by
111+
* the `splitTheme` decorator and the parameters passed to the story. If
112+
* background colours are set via the `colourSchemeBackground` parameter, these
113+
* are used, otherwise defaults from {@linkcode sourcePalette} are used.
114+
*
115+
* @param colourScheme Light or dark mode.
116+
* @param context A story's context, containing the story parameters.
117+
* @returns A CSS `color` value.
118+
*/
119+
const backgroundColour = (
120+
colourScheme: ColourScheme,
121+
context: Context,
122+
): string =>
123+
colourScheme === 'light'
124+
? context.parameters.colourSchemeBackground?.light ??
125+
sourcePalette.neutral[100]
126+
: context.parameters.colourSchemeBackground?.dark ??
127+
sourcePalette.neutral[0];
128+
129+
/**
130+
* Derives the text colour for a story based on the colour scheme set by the
131+
* `splitTheme` decorator and the parameters passed to the story. If text
132+
* colours are set via the `colourSchemeTextColour` parameter, these are used,
133+
* otherwise defaults from {@linkcode sourcePalette} are used.
134+
*
135+
* @param colourScheme Light or dark mode.
136+
* @param context A story's context, containing the story parameters.
137+
* @returns A CSS `color` value.
138+
*/
139+
const textColour = (colourScheme: ColourScheme, context: Context): string =>
140+
colourScheme === 'light'
141+
? context.parameters.colourSchemeTextColour?.light ??
142+
sourcePalette.neutral[0]
143+
: context.parameters.colourSchemeTextColour?.dark ??
144+
sourcePalette.neutral[100];
145+
146+
/**
147+
* Describes the theme being used to render the Story, 'light' or 'dark'.
148+
*/
149+
const ThemeHeading = ({ colourScheme }: { colourScheme: ColourScheme }) => (
150+
<h2
151+
css={[
152+
textSansBold20,
153+
{
154+
textAlign: 'center',
155+
padding: space[2],
156+
},
157+
]}
158+
>
159+
{colourScheme === 'light' ? 'Light Theme ☀️' : 'Dark Theme 🌙'}
160+
</h2>
161+
);
162+
163+
type FormatHeadingProps = {
164+
colourScheme: ColourScheme;
165+
format: ArticleFormat;
166+
};
167+
168+
/**
169+
* Describes the {@linkcode ArticleFormat} being used to render the story.
170+
*/
171+
const FormatHeading = ({ format, colourScheme }: FormatHeadingProps) => (
172+
<h3
173+
css={[
174+
textSans17,
175+
{
176+
textAlign: 'center',
177+
padding: space[1],
178+
color:
179+
colourScheme === 'light'
180+
? sourcePalette.neutral[20]
181+
: sourcePalette.neutral[73],
182+
},
183+
]}
184+
>
185+
{[
186+
`Display: ${ArticleDisplay[format.display]}`,
187+
`Design: ${ArticleDesign[format.design]}`,
188+
`Theme: ${Pillar[format.theme] || ArticleSpecial[format.theme]}`,
189+
]
190+
.map((line) => line.replaceAll(' ', ' ')) // non-breaking spaces
191+
.join(', ')}
192+
</h3>
193+
);
194+
195+
type PaletteProps = {
196+
format: ArticleFormat;
197+
colourScheme: ColourScheme;
198+
context: Context;
199+
children: ReactNode;
200+
};
201+
202+
/**
203+
* Generates the palette colours using the given {@linkcode ArticleFormat} and
204+
* {@linkcode ColourScheme} and makes them available to the Story.
205+
*
206+
* For more information on how the palette works see
207+
* {@linkcode paletteDeclarations}.
208+
*/
209+
const Palette = ({ format, colourScheme, context, children }: PaletteProps) => (
210+
<div
211+
data-color-scheme={colourScheme}
212+
css={[
213+
css(paletteDeclarations(format, colourScheme)),
214+
{
215+
backgroundColor: backgroundColour(colourScheme, context),
216+
color: textColour(colourScheme, context),
217+
},
218+
]}
219+
>
220+
{children}
221+
</div>
222+
);
223+
224+
type ThemeProps = {
225+
formats: ArticleFormat[];
226+
Story: Parameters<Decorator>[0];
227+
context: Context;
228+
colourScheme: ColourScheme;
229+
};
230+
231+
/**
232+
* Renders a story one or more times, based on the list of
233+
* {@linkcode ArticleFormat}s passed, and in a particular
234+
* {@linkcode ColourScheme}.
235+
*
236+
* For example, if a single format is passed and the colour scheme is 'light',
237+
* it will render the story once in light mode. If three formats are passed and
238+
* the colours scheme is 'dark', it will render the story three times, once for
239+
* each format, and all three will be in dark mode.
240+
*
241+
* Also sets default background and text colours supplied via the
242+
* `colourSchemeBackground` and `colourSchemeTextColour` parameters from the
243+
* story, or provides defaults when these are not supplied.
244+
*/
245+
const Theme = ({ formats, Story, context, colourScheme }: ThemeProps) => (
246+
<div
247+
css={{
248+
color:
249+
colourScheme === 'light'
250+
? sourcePalette.neutral[0]
251+
: sourcePalette.neutral[100],
252+
backgroundColor:
253+
colourScheme === 'light'
254+
? sourcePalette.neutral[100]
255+
: sourcePalette.neutral[0],
256+
}}
257+
>
258+
<ThemeHeading colourScheme={colourScheme} />
259+
{formats.map((format) => (
260+
<>
261+
<FormatHeading format={format} colourScheme={colourScheme} />
262+
<Palette
263+
colourScheme={colourScheme}
264+
context={context}
265+
format={format}
266+
>
267+
<Story
268+
args={{
269+
...context.args,
270+
format,
271+
}}
272+
/>
273+
</Palette>
274+
</>
275+
))}
276+
</div>
277+
);
278+
279+
/**
280+
* Creates a Storybook decorator used to render a story in both light and dark
281+
* mode simultaneously; either vertically, light above dark, or horizontally,
282+
* light on the left and dark on the right.
283+
*
284+
* If multiple {@linkcode ArticleFormat}s are passed, it will render the story
285+
* in both light and dark mode for each of these. For example, if three formats
286+
* are passed then it will render the story six times, three times in light
287+
* mode, once for each format, and three times in dark mode, once for each
288+
* format.
289+
*
290+
* The returned "splitTheme" decorator was historically used directly in story
291+
* files. This approach is now deprecated in favour of the "global colour
292+
* scheme" decorator and toolbar item, which use the "splitTheme" decorator in
293+
* turn. The "global colour scheme" can be set in Storybook via the toolbar, and
294+
* in Chromatic via "modes".
295+
*
296+
* @param formats A list of formats to render the story in. If none are passed
297+
* then the {@linkcode defaultFormats} are used.
298+
* @param orientation Whether to render light and dark mode side-by-side
299+
* vertically or horizontally. The default is `horizontal`.
300+
* @returns A decorator that can be used with Storybook.
132301
*/
133302
export const splitTheme =
134303
(
@@ -137,64 +306,25 @@ export const splitTheme =
137306
): Decorator =>
138307
(Story, context) => (
139308
<div
140-
css={styles}
141-
style={{
309+
css={{
310+
display: 'grid',
311+
maxWidth: '100%',
142312
gridTemplateColumns:
143313
orientation === 'horizontal' ? '1fr 1fr' : '1fr',
144314
}}
145315
>
146-
<div
147-
data-color-scheme="light"
148-
style={{
149-
backgroundColor:
150-
context.parameters.colourSchemeBackground?.light ??
151-
sourcePalette.neutral[100],
152-
color:
153-
context.parameters.colourSchemeTextColour?.light ??
154-
sourcePalette.neutral[0],
155-
}}
156-
css={css(paletteDeclarations(defaultFormats[0], 'light'))}
157-
>
158-
<h2 css={headerCss}>Light Theme ☀️</h2>
159-
{formats.map((format) => (
160-
<div css={css(paletteDeclarations(format, 'light'))}>
161-
<FormatHeading format={format} />
162-
<Story
163-
args={{
164-
...context.args,
165-
format,
166-
theme: 'light',
167-
}}
168-
/>
169-
</div>
170-
))}
171-
</div>
172-
<div
173-
data-color-scheme="dark"
174-
style={{
175-
backgroundColor:
176-
context.parameters.colourSchemeBackground?.dark ??
177-
sourcePalette.neutral[0],
178-
color:
179-
context.parameters.colourSchemeTextColour?.dark ??
180-
sourcePalette.neutral[100],
181-
}}
182-
css={css(paletteDeclarations(defaultFormats[0], 'dark'))}
183-
>
184-
<h2 css={headerCss}>Dark Theme 🌙</h2>
185-
{formats.map((format) => (
186-
<div css={css(paletteDeclarations(format, 'dark'))}>
187-
<FormatHeading format={format} />
188-
<Story
189-
args={{
190-
...context.args,
191-
format,
192-
theme: 'dark',
193-
}}
194-
/>
195-
</div>
196-
))}
197-
</div>
316+
<Theme
317+
colourScheme="light"
318+
formats={formats}
319+
Story={Story}
320+
context={context}
321+
/>
322+
<Theme
323+
colourScheme="dark"
324+
formats={formats}
325+
Story={Story}
326+
context={context}
327+
/>
198328
</div>
199329
);
200330

0 commit comments

Comments
 (0)