Skip to content

Commit

Permalink
Implement multi theme feature based on data attributes (#1756)
Browse files Browse the repository at this point in the history
<!--
  How to write a good PR title:
- Follow [the Conventional Commits
specification](https://www.conventionalcommits.org/en/v1.0.0/).
  - Give as much context as necessary and as little as possible
  - Prefix it with [WIP] while it’s a work in progress
-->

## Self Checklist

- [x] I wrote a PR title in **English** and added an appropriate
**label** to the PR.
- [x] I wrote the commit message in **English** and to follow [**the
Conventional Commits
specification**](https://www.conventionalcommits.org/en/v1.0.0/).
- [x] I [added the
**changeset**](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md)
about the changes that needed to be released. (or didn't have to)
- [x] I wrote or updated **documentation** related to the changes. (or
didn't have to)
- [ ] I wrote or updated **tests** related to the changes. (or didn't
have to)
- [x] I tested the changes in various browsers. (or didn't have to)
  - Windows: Chrome, Edge, (Optional) Firefox
  - macOS: Chrome, Edge, Safari, (Optional) Firefox

## Related Issue
<!-- Please link to issue if one exists -->

Fixes #1690

## Summary
<!-- Please brief explanation of the changes made -->

data attributes를 기반으로 한 멀티 테마 기능을 구현합니다.

## Details
<!-- Please elaborate description of the changes -->

foundation 객체를 주입받는 형식에서, themeNam 문자열을 주입받아 하위 엘리먼트(루트 엘리먼트가 되길 기대)의
data attribute를 변경 & 해당하는 토큰 객체를 컨텍스트로 전달하는 AppProvider를 구현합니다.

- features prop: 사용 편의성을 위해 `FeatureProvider` 를 빌트인으로 가지는 방향으로 변경했습니다.
기본값은 빈 배열입니다.
- BezierProvider to AppProvider: 앱의 루트에 적용하는 Provider라는 점을 강조하기 위해
Bezier 대신 App이라는 접두어를 붙였습니다.

라이트테마, 다크테마 혹은 테마 역전(Tooltip 등)이 필요한 곳에서 테마를 고정해서 사용할 수 있는
ThemeProvider를 구현합니다. 이제 `--inverted-` 토큰이 사라지는 대신, 하위 엘리먼트에 radix-ui
Slot을 통해 data theme attribute를 전달하여 토큰을 스위칭하는 방식으로 동작하게 됩니다.

### Breaking change? (Yes/No)
<!-- If Yes, please describe the impact and migration path for users -->

No. 기존 BezierProvider는 그대로 유지합니다.

## References
<!-- Please list any other resources or points the reviewer should be
aware of -->

- https://www.radix-ui.com/themes/docs/components/theme
- https://panda-css.com/docs/guides/multiple-themes
-
https://github.com/Shopify/polaris/blob/main/polaris-react/src/components/AppProvider/AppProvider.tsx
  • Loading branch information
sungik-choi authored Dec 1, 2023
1 parent 49e464b commit 8f88a03
Show file tree
Hide file tree
Showing 8 changed files with 275 additions and 22 deletions.
5 changes: 5 additions & 0 deletions .changeset/chilled-dots-divide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@channel.io/bezier-react": minor
---

Implement multi theme feature based on data attributes.
1 change: 1 addition & 0 deletions packages/bezier-react/src/features/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export type { Feature } from './Feature'
export { FeatureType } from './Feature'

export {
Expand Down
14 changes: 13 additions & 1 deletion packages/bezier-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,19 @@ import '~/src/styles/index.scss'

/* Provider */
export { default as BezierProvider } from '~/src/providers/BezierProvider'
export { default as WindowProvider, useWindow } from '~/src/providers/WindowProvider'
export * from '~/src/providers/WindowProvider'
export * from '~/src/providers/AlphaAppProvider'
export {
useThemeName,
useToken,
ThemeProvider,
LightThemeProvider,
DarkThemeProvider,
InvertedThemeProvider,
type ThemeName,
type ThemeProviderProps,
type FixedThemeProviderProps,
} from '~/src/providers/ThemeProvider'

/* Foundation */
export * from '~/src/foundation'
Expand Down
85 changes: 85 additions & 0 deletions packages/bezier-react/src/providers/AlphaAppProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import React, { useEffect } from 'react'

import {
type Feature,
FeatureProvider,
} from '~/src/features'
import { window as defaultWindow } from '~/src/utils/dom'

import { TooltipProvider } from '~/src/components/Tooltip'

import {
type ThemeName,
TokenProvider,
} from './ThemeProvider'
import { WindowProvider } from './WindowProvider'

export interface AlphaAppProviderProps {
children: React.ReactNode
/**
* Name of the theme to use for the app.
* @default 'light'
*/
themeName?: ThemeName
/**
* List of features to enable for the app.
* @default []
*/
features?: Feature[]
/**
* Window object to use for the app.
* @default window
*/
window?: Window
}

/**
* `AlphaAppProvider` is a required wrapper component that provides context for the app.
*
* @example
*
* ```tsx
* import React from 'react'
* import { createRoot } from 'react-dom/client'
* import { AlphaAppProvider } from '@channel.io/bezier-react'
*
* const container = document.getElementById('root')
* const root = createRoot(container)
*
* root.render(
* <AlphaAppProvider themeName="light">
* <App />
* </AlphaAppProvider>,
* )
* ```
*/
export function AlphaAppProvider({
children,
themeName = 'light',
features = [],
window = defaultWindow,
}: AlphaAppProviderProps) {
useEffect(function updateRootThemeDataAttribute() {
const rootElement = window.document.documentElement
// TODO: Change data attribute constant to import from bezier-tokens
rootElement.setAttribute('data-bezier-theme', themeName)
return function cleanup() {
rootElement.removeAttribute('data-bezier-theme')
}
}, [
window,
themeName,
])

return (
<WindowProvider window={window}>
<FeatureProvider features={features}>
<TokenProvider themeName={themeName}>
<TooltipProvider>
{ children }
</TooltipProvider>
</TokenProvider>
</FeatureProvider>
</WindowProvider>
)
}
14 changes: 4 additions & 10 deletions packages/bezier-react/src/providers/BezierProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,11 @@ import {
type ThemeVarsAdditionalType,
} from '~/src/foundation'

import {
document as defaultDocument,
window as defaultWindow,
} from '~/src/utils/dom'
import { window as defaultWindow } from '~/src/utils/dom'

import { TooltipProvider } from '~/src/components/Tooltip'

import WindowProvider from './WindowProvider'
import { WindowProvider } from './WindowProvider'

interface BezierProviderProps {
foundation: Foundation & GlobalStyleProp
Expand All @@ -29,13 +26,10 @@ function BezierProvider({
foundation,
children,
themeVarsScope,
externalWindow,
externalWindow = defaultWindow,
}: BezierProviderProps) {
return (
<WindowProvider
window={externalWindow ?? defaultWindow}
document={externalWindow?.document ?? defaultDocument}
>
<WindowProvider window={externalWindow}>
<FoundationProvider foundation={foundation}>
<TooltipProvider>
<GlobalStyle foundation={foundation} />
Expand Down
151 changes: 151 additions & 0 deletions packages/bezier-react/src/providers/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import React, {
forwardRef,
useMemo,
} from 'react'

import { tokens } from '@channel.io/bezier-tokens'
import { Slot } from '@radix-ui/react-slot'

import { createContext } from '~/src/utils/react'

type Tokens = typeof tokens
type GlobalTokens = Tokens['global']
type SemanticTokens = Omit<Tokens, 'global'>

interface ThemedTokenSet {
global: GlobalTokens
semantic: SemanticTokens[keyof SemanticTokens]
}

// TODO: Change theme name constant to import from bezier-tokens
export type ThemeName = 'light' | 'dark'

interface TokenContextValue {
themeName: ThemeName
tokens: ThemedTokenSet
}

const [TokenContextProvider, useTokenContext] = createContext<TokenContextValue | null>(null, 'TokenProvider')

const tokenSet: Record<ThemeName, ThemedTokenSet> = Object.freeze({
light: {
global: tokens.global,
semantic: tokens.lightTheme,
},
dark: {
global: tokens.global,
semantic: tokens.darkTheme,
},
})

interface TokenProviderProps {
themeName: ThemeName
children: React.ReactNode
}

/**
* @private For internal use only.
*/
export function TokenProvider({
themeName,
children,
}: TokenProviderProps) {
return (
<TokenContextProvider value={useMemo(() => ({
themeName,
tokens: tokenSet[themeName],
}), [themeName])}
>
{ children }
</TokenContextProvider>
)
}

/**
* `useThemeName` is a hook that returns the current theme name.
*/
export function useThemeName() {
return useTokenContext('useThemeName').themeName
}

/**
* `useToken` is a hook that returns the design token for the current theme.
*/
export function useToken() {
return useTokenContext('useToken').tokens
}

export interface ThemeProviderProps {
themeName: ThemeName
children: React.ReactElement
}

export type FixedThemeProviderProps = Omit<ThemeProviderProps, 'themeName'>

/**
* `ThemeProvider` is a wrapper component that provides theme context.
*/
export const ThemeProvider = forwardRef<HTMLElement, ThemeProviderProps>(function ThemeProvider({
themeName,
children,
}, forwardedRef) {
return (
<TokenProvider themeName={themeName}>
<Slot
ref={forwardedRef}
// TODO: Change data attribute constant to import from bezier-tokens
data-bezier-theme={themeName}
>
{ children }
</Slot>
</TokenProvider>
)
})

/**
* `LightThemeProvider` is a wrapper component that provides light theme context.
*/
export const LightThemeProvider = forwardRef<HTMLElement, FixedThemeProviderProps>(function LightTheme({
children,
}, forwardedRef) {
return (
<ThemeProvider
ref={forwardedRef}
themeName="light"
>
{ children }
</ThemeProvider>
)
})

/**
* `DarkThemeProvider` is a wrapper component that provides dark theme context.
*/
export const DarkThemeProvider = forwardRef<HTMLElement, FixedThemeProviderProps>(function DarkTheme({
children,
}, forwardedRef) {
return (
<ThemeProvider
ref={forwardedRef}
themeName="dark"
>
{ children }
</ThemeProvider>
)
})

/**
* `InvertedThemeProvider` is a wrapper component that provides inverted theme context.
*/
export const InvertedThemeProvider = forwardRef<HTMLElement, FixedThemeProviderProps>(function InvertedTheme({
children,
}, forwardedRef) {
return (
<ThemeProvider
ref={forwardedRef}
themeName={useThemeName() === 'light' ? 'dark' : 'light'}
>
{ children }
</ThemeProvider>
)
})
13 changes: 3 additions & 10 deletions packages/bezier-react/src/providers/WindowProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,34 +23,27 @@ interface WindowProviderProps extends PropsWithChildren {
* @required
*/
window: Window

/**
* injected document
* @required
*/
document: Document
}

/**
* A Provider that provides window and document object
* you can use this provider to inject an external window
*/
function WindowProvider({ window, document, children }: WindowProviderProps) {
export function WindowProvider({ window, children }: WindowProviderProps) {
const document = window.document
const rootElement = document.body

const value = useMemo(() => ({
window,
document,
rootElement,
}), [
document,
window,
document,
rootElement,
])

return (
<WindowContextProvider value={value}>{ children }</WindowContextProvider>
)
}

export default WindowProvider
14 changes: 13 additions & 1 deletion packages/bezier-react/src/styles/_base.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
html {
:where(:root, :host) {
font-size: 62.5%; // 10/16 = 0.625. Make REM calculations easier.
font-family: var(--font-family-sans-kr);
color: var(--txt-black-darkest);
color-scheme: light;
}

// TODO: Change data attribute constant to import from bezier-tokens
[data-bezier-theme='light'] {
color-scheme: light;
}

// TODO: Change data attribute constant to import from bezier-tokens
[data-bezier-theme='dark'] {
color-scheme: dark;
}

:lang(ja) {
Expand Down

0 comments on commit 8f88a03

Please sign in to comment.