Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 0 additions & 26 deletions .storybook/dark-theme.tsx

This file was deleted.

45 changes: 41 additions & 4 deletions .storybook/preview-head.html
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,17 @@
left: 0;
right: 0;
padding: 20px;
font-family: 'Nunito Sans', -apple-system, '.SFNSText-Regular', 'San Francisco',
BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-family:
'Nunito Sans',
-apple-system,
'.SFNSText-Regular',
'San Francisco',
BlinkMacSystemFont,
'Segoe UI',
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
-webkit-font-smoothing: antialiased;
overflow: auto;
}
Expand Down Expand Up @@ -106,8 +115,9 @@
padding: 10px;
background: #000;
color: #eee;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Cascadia Mono',
Consolas, 'Liberation Mono', 'Courier New', monospace;
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Cascadia Mono', Consolas,
'Liberation Mono', 'Courier New', monospace;
}

.sb-errordisplay pre {
Expand All @@ -124,3 +134,30 @@
display: none !important;
}
</style>
<script>
/*
* Applies the theme class before the document paints. Storybook reloads the
* preview iframe on every globals change in docs view, and without this the
* reloaded page paints unthemed until React mounts, which reads as a flash.
*
* Repeats getThemeClassNameFromSearch in .storybook/theming/theme-class.ts,
* and the DEFAULT_ACCENT and DEFAULT_MODE values in its constants.ts. This
* file is plain HTML and cannot import them. Change them together.
*/
;(function initTheme() {
try {
var globals = new URLSearchParams(window.location.search).get('globals') || ''
var parsed = {}
globals.split(';').forEach(function (pair) {
var parts = pair.split(':')
if (parts.length === 2) parsed[parts[0]] = parts[1]
})
var accent = parsed.theme || 'red'
var mode = parsed.mode === 'dark' ? 'dark' : 'light'
var suffix = accent === 'red' ? mode : mode === 'dark' ? accent + '_dark' : accent
document.documentElement.classList.add('theme_' + suffix)
} catch (error) {
// This file is not transpiled. A parsing failure must not break the preview.
}
})()
</script>
18 changes: 16 additions & 2 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
import '@doist/product-libraries-tokens/css/td-light.css'
import '../src/styles/design-tokens.css'
import '../stories/components/styles/story.css'

import { create } from 'storybook/theming/create'

import BaseDecorator from './BaseDecorator'
import { reactistBadgeTones } from './components/badge-tones'
import {
DEFAULT_ACCENT,
DEFAULT_MODE,
modeToolbarConfig,
ThemeDecorator,
themeToolbarConfig,
} from './theming'

import type { Preview } from '@storybook/react-vite'

const preview: Preview = {
decorators: [BaseDecorator],
decorators: [ThemeDecorator, BaseDecorator],
globalTypes: {
theme: themeToolbarConfig,
mode: modeToolbarConfig,
},
initialGlobals: {
theme: DEFAULT_ACCENT,
mode: DEFAULT_MODE,
},
parameters: {
viewMode: 'docs',
docs: {
Expand Down
41 changes: 41 additions & 0 deletions .storybook/theming/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export type ThemeAccent =
| 'red'
| 'blueberry'
| 'gold'
| 'kale'
| 'lavender'
| 'moonstone'
| 'raspberry'
| 'tangerine'

export type ThemeMode = 'light' | 'dark'

/** Prefix every Product Library theme class shares. Used to strip the previous theme. */
export const THEME_CLASS_PREFIX = 'theme_'

/**
* The accent axis of the toolbar. `red` is the default Todoist pair,
* `theme_light` and `theme_dark`.
*/
export const THEME_ACCENTS: ReadonlyArray<{ value: ThemeAccent; title: string }> = [
{ value: 'red', title: 'Red' },
{ value: 'blueberry', title: 'Blueberry' },
{ value: 'gold', title: 'Gold' },
{ value: 'kale', title: 'Kale' },
{ value: 'lavender', title: 'Lavender' },
{ value: 'moonstone', title: 'Moonstone' },
{ value: 'raspberry', title: 'Raspberry' },
{ value: 'tangerine', title: 'Tangerine' },
]

export const THEME_MODES: ReadonlyArray<{ value: ThemeMode; title: string }> = [
{ value: 'light', title: 'Light' },
{ value: 'dark', title: 'Dark' },
]

/**
* The toolbar's starting selection, used for `initialGlobals` in `preview.ts`.
* The inline script in `.storybook/preview-head.html` repeats these values.
*/
export const DEFAULT_ACCENT: ThemeAccent = 'red'
export const DEFAULT_MODE: ThemeMode = 'light'
3 changes: 3 additions & 0 deletions .storybook/theming/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './constants'
export * from './theme-class'
export * from './theme-decorator'
70 changes: 70 additions & 0 deletions .storybook/theming/theme-class.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { THEME_ACCENTS, THEME_MODES } from './constants'
import { applyThemeClass, getThemeClassName, getThemeClassNameFromSearch } from './theme-class'

describe('getThemeClassName', () => {
it('drops the accent for the red pair', () => {
expect(getThemeClassName('red', 'light')).toBe('theme_light')
expect(getThemeClassName('red', 'dark')).toBe('theme_dark')
})

it('names an accent alone in light mode', () => {
expect(getThemeClassName('blueberry', 'light')).toBe('theme_blueberry')
})

it('suffixes an accent with an underscore in dark mode', () => {
expect(getThemeClassName('blueberry', 'dark')).toBe('theme_blueberry_dark')
})

it('covers every file the token package ships', () => {
const classNames = THEME_ACCENTS.flatMap((accent) =>
THEME_MODES.map((mode) => getThemeClassName(accent.value, mode.value)),
)
expect(new Set(classNames).size).toBe(16)
})
})

describe('getThemeClassNameFromSearch', () => {
it('falls back to the toolbar defaults when the param is absent', () => {
expect(getThemeClassNameFromSearch('?id=button--docs')).toBe('theme_light')
})

it('reads a mode Storybook wrote on its own', () => {
expect(getThemeClassNameFromSearch('?globals=mode%3Adark')).toBe('theme_dark')
})

it('reads an accent and a mode together', () => {
expect(getThemeClassNameFromSearch('?globals=theme%3Ablueberry%3Bmode%3Adark')).toBe(
'theme_blueberry_dark',
)
})

it('keeps the default mode when only the accent is named', () => {
expect(getThemeClassNameFromSearch('?globals=theme%3Akale')).toBe('theme_kale')
})

it('ignores globals belonging to other toolbars', () => {
expect(getThemeClassNameFromSearch('?globals=locale%3Ade%3Bmode%3Adark')).toBe('theme_dark')
})
})

describe('applyThemeClass', () => {
it('adds the class to an element that has none', () => {
const root = document.createElement('html')
applyThemeClass(root, 'theme_dark')
expect(root.className).toBe('theme_dark')
})

it('replaces a previous theme class', () => {
const root = document.createElement('html')
root.classList.add('theme_blueberry_dark')
applyThemeClass(root, 'theme_kale')
expect(root.className).toBe('theme_kale')
})

it('leaves classes that are not themes alone', () => {
const root = document.createElement('html')
root.classList.add('sb-show-main', 'theme_dark')
applyThemeClass(root, 'theme_light')
expect([...root.classList].sort()).toEqual(['sb-show-main', 'theme_light'])
})
})
43 changes: 43 additions & 0 deletions .storybook/theming/theme-class.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { DEFAULT_ACCENT, THEME_CLASS_PREFIX } from './constants'

import type { ThemeAccent, ThemeMode } from './constants'

/**
* Builds the class name `@doist/product-libraries-tokens` generates for an accent and a mode.
* Example: `red` + `dark` = `theme_dark`, `blueberry` + `dark` = `theme_blueberry_dark`.
*/
export function getThemeClassName(accent: ThemeAccent, mode: ThemeMode): string {
if (accent === 'red') return `${THEME_CLASS_PREFIX}${mode}`
return mode === 'dark'
? `${THEME_CLASS_PREFIX}${accent}_dark`
: `${THEME_CLASS_PREFIX}${accent}`
}

/**
* Reads Storybook's `globals` query param and returns the theme class it names.
* Storybook writes it as `theme:blueberry;mode:dark`, and omits any global still
* at its `initialGlobals` value.
*
* The inline script in `.storybook/preview-head.html` repeats this rule. Change
* both together.
*/
export function getThemeClassNameFromSearch(search: string): string {
const globals = new URLSearchParams(search).get('globals') ?? ''
const parsed = new Map(
globals
.split(';')
.map((pair) => pair.split(':'))
.filter((parts): parts is [string, string] => parts.length === 2),
)
const accent = (parsed.get('theme') ?? DEFAULT_ACCENT) as ThemeAccent
const mode = parsed.get('mode') === 'dark' ? 'dark' : 'light'
return getThemeClassName(accent, mode)
}

/** Swaps whichever theme class the element carries for `className`. */
export function applyThemeClass(root: HTMLElement, className: string): void {
for (const existing of [...root.classList]) {
if (existing.startsWith(THEME_CLASS_PREFIX)) root.classList.remove(existing)
}
root.classList.add(className)
}
63 changes: 63 additions & 0 deletions .storybook/theming/theme-decorator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import '@doist/product-libraries-tokens/css/td-light.css'
import '@doist/product-libraries-tokens/css/td-dark.css'
import '@doist/product-libraries-tokens/css/td-blueberry.css'
import '@doist/product-libraries-tokens/css/td-blueberry-dark.css'
import '@doist/product-libraries-tokens/css/td-gold.css'
import '@doist/product-libraries-tokens/css/td-gold-dark.css'
import '@doist/product-libraries-tokens/css/td-kale.css'
import '@doist/product-libraries-tokens/css/td-kale-dark.css'
import '@doist/product-libraries-tokens/css/td-lavender.css'
import '@doist/product-libraries-tokens/css/td-lavender-dark.css'
import '@doist/product-libraries-tokens/css/td-moonstone.css'
import '@doist/product-libraries-tokens/css/td-moonstone-dark.css'
import '@doist/product-libraries-tokens/css/td-raspberry.css'
import '@doist/product-libraries-tokens/css/td-raspberry-dark.css'
import '@doist/product-libraries-tokens/css/td-tangerine.css'
import '@doist/product-libraries-tokens/css/td-tangerine-dark.css'
import './theme-surfaces.css'

import * as React from 'react'

import { THEME_ACCENTS, THEME_MODES } from './constants'
import { applyThemeClass, getThemeClassName } from './theme-class'

import type { ThemeAccent, ThemeMode } from './constants'
import type { Decorator } from '@storybook/react-vite'

type StoryContext = Parameters<Decorator>[1]

/** Renders a story under the Product Library theme the toolbar selects. */
export function ThemeDecorator(Story: React.ComponentType, { globals }: StoryContext) {
const accent = globals.theme as ThemeAccent
const mode = globals.mode as ThemeMode
const className = getThemeClassName(accent, mode)

React.useLayoutEffect(
function applyTheme() {
applyThemeClass(document.documentElement, className)
},
[className],
)

return <Story />
}

export const themeToolbarConfig = {
name: 'Theme',
description: 'Product Library accent',
toolbar: {
icon: 'paintbrush',
dynamicTitle: true,
items: THEME_ACCENTS.map(({ value, title }) => ({ value, title })),
},
} as const

export const modeToolbarConfig = {
name: 'Mode',
description: 'Light or dark',
toolbar: {
icon: 'contrast',
dynamicTitle: true,
items: THEME_MODES.map(({ value, title }) => ({ value, title })),
},
} as const
18 changes: 18 additions & 0 deletions .storybook/theming/theme-surfaces.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* Paints the preview iframe's story surfaces from the selected theme.
*
* The docs page itself keeps the light background `docs.theme` gives it, because
* Storybook paints its prose for a light page. Only the Canvas blocks follow the
* theme, so a story renders on the surface its own tokens assume.
*
* The [class*='theme_'] prefix raises specificity to (0,2,1), above the (0,1,0)
* Emotion class the docs theme paints .sbdocs-preview with.
*/
body {
background-color: var(--product-library-background-base-secondary);
color: var(--product-library-display-primary-idle-tint);
}

:root[class*='theme_'] .sbdocs-preview {
background-color: var(--product-library-background-base-secondary);
}
4 changes: 1 addition & 3 deletions src/badge/badge.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,4 @@ The following CSS custom properties are available to customize the badge compone

### Dark mode

Dark mode colors are applied when the `.theme_dark` class is set on the root element.

<Canvas of={BadgeStories.DarkMode} />
Dark mode colors are applied when the `.theme_dark` class is set on the root element. Use the **Mode** control in the toolbar to render any story in dark.
Loading
Loading