Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 11 KiB |
@@ -1,4 +1,3 @@
|
||||
import type { ThemeProviderProps } from '@primer/react'
|
||||
import { useEffect } from 'react'
|
||||
import useSWR from 'swr'
|
||||
|
||||
@@ -14,16 +13,6 @@ export type Session = {
|
||||
isSignedIn: boolean
|
||||
csrfToken?: string
|
||||
userLanguage: string // en, es, ja, cn
|
||||
theme: {
|
||||
colorMode: Pick<ThemeProviderProps, 'colorMode'>
|
||||
nightTheme: string
|
||||
dayTheme: string
|
||||
}
|
||||
themeCss: {
|
||||
colorMode: Pick<ThemeProviderProps, 'colorMode'>
|
||||
nightTheme: string
|
||||
dayTheme: string
|
||||
}
|
||||
}
|
||||
|
||||
// React hook version
|
||||
|
||||
114
components/hooks/useTheme.ts
Normal file
114
components/hooks/useTheme.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
enum CssColorMode {
|
||||
auto = 'auto',
|
||||
light = 'light',
|
||||
dark = 'dark',
|
||||
}
|
||||
|
||||
enum ComponentColorMode {
|
||||
auto = 'auto',
|
||||
day = 'day',
|
||||
night = 'night',
|
||||
}
|
||||
|
||||
enum SupportedTheme {
|
||||
light = 'light',
|
||||
dark = 'dark',
|
||||
dark_dimmed = 'dark_dimmed',
|
||||
dark_high_contrast = 'dark_high_contrast',
|
||||
}
|
||||
|
||||
type CssColorTheme = {
|
||||
colorMode: CssColorMode
|
||||
lightTheme: SupportedTheme
|
||||
darkTheme: SupportedTheme
|
||||
}
|
||||
|
||||
type ComponentColorTheme = {
|
||||
colorMode: ComponentColorMode
|
||||
dayScheme: SupportedTheme
|
||||
nightScheme: SupportedTheme
|
||||
}
|
||||
|
||||
type ColorModeThemes = {
|
||||
css: CssColorTheme
|
||||
component: ComponentColorTheme
|
||||
}
|
||||
|
||||
export const defaultCSSTheme: CssColorTheme = {
|
||||
colorMode: CssColorMode.auto,
|
||||
lightTheme: SupportedTheme.light,
|
||||
darkTheme: SupportedTheme.dark,
|
||||
}
|
||||
|
||||
export const defaultComponentTheme: ComponentColorTheme = {
|
||||
colorMode: ComponentColorMode.auto,
|
||||
dayScheme: SupportedTheme.light,
|
||||
nightScheme: SupportedTheme.dark,
|
||||
}
|
||||
|
||||
const cssColorModeToComponentColorMode: Record<CssColorMode, ComponentColorMode> = {
|
||||
[CssColorMode.auto]: ComponentColorMode.auto,
|
||||
[CssColorMode.light]: ComponentColorMode.day,
|
||||
[CssColorMode.dark]: ComponentColorMode.night,
|
||||
}
|
||||
|
||||
function filterMode(mode = ''): CssColorMode | undefined {
|
||||
if (Object.values<string>(CssColorMode).includes(mode)) {
|
||||
return mode as CssColorMode
|
||||
}
|
||||
}
|
||||
|
||||
function filterTheme({ name = '', color_mode = '' } = {}): SupportedTheme | undefined {
|
||||
if (Object.values<string>(SupportedTheme).includes(name)) {
|
||||
return name as SupportedTheme
|
||||
}
|
||||
if (Object.values<string>(SupportedTheme).includes(color_mode)) {
|
||||
return color_mode as SupportedTheme
|
||||
}
|
||||
}
|
||||
|
||||
export function getCssTheme(cookieValue = ''): CssColorTheme {
|
||||
if (!cookieValue) return defaultCSSTheme
|
||||
try {
|
||||
const parsed = JSON.parse(cookieValue)
|
||||
const { color_mode, light_theme, dark_theme } = parsed
|
||||
return {
|
||||
colorMode: filterMode(color_mode) || defaultCSSTheme.colorMode,
|
||||
lightTheme: filterTheme(light_theme) || defaultCSSTheme.lightTheme,
|
||||
darkTheme: filterTheme(dark_theme) || defaultCSSTheme.darkTheme,
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Unable to parse 'color_mode' cookie", err)
|
||||
return defaultCSSTheme
|
||||
}
|
||||
}
|
||||
|
||||
export function getComponentTheme(cookieValue = ''): ComponentColorTheme {
|
||||
const { colorMode, lightTheme, darkTheme } = getCssTheme(cookieValue)
|
||||
return {
|
||||
// The cookie value is a primer/css color_mode.
|
||||
// We need to convert that to a primer/react compatible version.
|
||||
colorMode: cssColorModeToComponentColorMode[colorMode],
|
||||
dayScheme: lightTheme,
|
||||
nightScheme: darkTheme,
|
||||
}
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<ColorModeThemes>({
|
||||
css: defaultCSSTheme,
|
||||
component: defaultComponentTheme,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const cookieValue = Cookies.get('color_mode')
|
||||
const css = getCssTheme(cookieValue)
|
||||
const component = getComponentTheme(cookieValue)
|
||||
setTheme({ css, component })
|
||||
}, [])
|
||||
|
||||
return { theme }
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
export const defaultCSSTheme = {
|
||||
colorMode: 'auto', // light, dark, auto
|
||||
nightTheme: 'dark',
|
||||
dayTheme: 'light',
|
||||
}
|
||||
|
||||
export const defaultComponentTheme = {
|
||||
colorMode: 'auto', // day, night, auto
|
||||
nightTheme: 'dark',
|
||||
dayTheme: 'light',
|
||||
}
|
||||
|
||||
const cssColorModeToComponentColorMode = {
|
||||
auto: 'auto',
|
||||
light: 'day',
|
||||
dark: 'night',
|
||||
}
|
||||
|
||||
const supportedThemes = ['light', 'dark', 'dark_dimmed']
|
||||
|
||||
/*
|
||||
* Our version of primer/css is out of date, so we can only support known themes.
|
||||
* For the least jarring experience possible, we fallback to the color_mode (light / dark) if provided by the theme, otherwise our own defaults
|
||||
*/
|
||||
function getSupportedTheme(theme, fallbackTheme) {
|
||||
if (!theme) {
|
||||
return fallbackTheme
|
||||
}
|
||||
|
||||
return supportedThemes.includes(theme.name) ? theme.name : theme.color_mode
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns theme for consumption by either primer/css or primer/components
|
||||
* based on the cookie and/or fallback values
|
||||
*/
|
||||
export function getTheme(req, cssMode = false) {
|
||||
const cookieValue = {}
|
||||
|
||||
const defaultTheme = cssMode ? defaultCSSTheme : defaultComponentTheme
|
||||
|
||||
if (req.cookies?.color_mode) {
|
||||
try {
|
||||
const parsed = JSON.parse(decodeURIComponent(req.cookies.color_mode))
|
||||
cookieValue.color_mode = parsed.color_mode
|
||||
cookieValue.dark_theme = parsed.dark_theme
|
||||
cookieValue.light_theme = parsed.light_theme
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
console.warn("Unable to parse 'color_mode' cookie", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The cookie value is a primer/css color_mode. sometimes we need to convert that to a primer/components compatible version
|
||||
const colorMode =
|
||||
(cssMode
|
||||
? cookieValue.color_mode
|
||||
: cssColorModeToComponentColorMode[cookieValue.color_mode || '']) || defaultTheme.colorMode
|
||||
|
||||
return {
|
||||
colorMode,
|
||||
nightTheme: getSupportedTheme(cookieValue.dark_theme, defaultTheme.nightTheme),
|
||||
dayTheme: getSupportedTheme(cookieValue.light_theme, defaultTheme.dayTheme),
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import express from 'express'
|
||||
import { getTheme } from '../../lib/get-theme.js'
|
||||
import { cacheControlFactory } from '../cache-control.js'
|
||||
|
||||
const router = express.Router()
|
||||
@@ -11,8 +10,6 @@ router.get('/', (req, res) => {
|
||||
isSignedIn: Boolean(req.cookies?.dotcom_user),
|
||||
csrfToken: req.csrfToken?.() || '',
|
||||
userLanguage: req.userLanguage,
|
||||
theme: getTheme(req),
|
||||
themeCss: getTheme(req, true),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect } from 'react'
|
||||
import App from 'next/app'
|
||||
import type { AppProps, AppContext } from 'next/app'
|
||||
import Head from 'next/head'
|
||||
import { ThemeProvider, SSRProvider, ThemeProviderProps } from '@primer/react'
|
||||
import { ThemeProvider, SSRProvider } from '@primer/react'
|
||||
|
||||
import '../stylesheets/index.scss'
|
||||
|
||||
@@ -10,16 +10,17 @@ import { initializeEvents } from 'components/lib/events'
|
||||
import experiment from 'components/lib/experiment'
|
||||
import { LanguagesContext, LanguagesContextT } from 'components/context/LanguagesContext'
|
||||
import { useSession } from 'components/hooks/useSession'
|
||||
import { useTheme } from 'components/hooks/useTheme'
|
||||
|
||||
type MyAppProps = AppProps & {
|
||||
isDotComAuthenticated: boolean
|
||||
languagesContext: LanguagesContextT
|
||||
}
|
||||
|
||||
type colorModeAuto = Pick<ThemeProviderProps, 'colorMode'>
|
||||
|
||||
const MyApp = ({ Component, pageProps, languagesContext }: MyAppProps) => {
|
||||
const { session } = useSession()
|
||||
const { theme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.csrfToken) {
|
||||
initializeEvents(session.csrfToken)
|
||||
@@ -54,18 +55,15 @@ const MyApp = ({ Component, pageProps, languagesContext }: MyAppProps) => {
|
||||
</Head>
|
||||
<SSRProvider>
|
||||
<ThemeProvider
|
||||
colorMode={
|
||||
(session?.theme?.colorMode as colorModeAuto['colorMode']) ||
|
||||
('auto' as colorModeAuto['colorMode'])
|
||||
}
|
||||
dayScheme={session?.theme?.dayTheme || 'light'}
|
||||
nightScheme={session?.theme?.nightTheme || 'dark'}
|
||||
colorMode={theme.component.colorMode}
|
||||
dayScheme={theme.component.dayScheme}
|
||||
nightScheme={theme.component.nightScheme}
|
||||
>
|
||||
{/* Appears Next.js can't modify <body> after server rendering: https://stackoverflow.com/a/54774431 */}
|
||||
<div
|
||||
data-color-mode={session?.themeCss?.colorMode || 'auto'}
|
||||
data-dark-theme={session?.themeCss?.nightTheme || 'dark'}
|
||||
data-light-theme={session?.themeCss?.dayTheme || 'light'}
|
||||
data-color-mode={theme.css.colorMode}
|
||||
data-light-theme={theme.css.lightTheme}
|
||||
data-dark-theme={theme.css.darkTheme}
|
||||
>
|
||||
<LanguagesContext.Provider value={languagesContext}>
|
||||
<Component {...pageProps} />
|
||||
|
||||
@@ -13,9 +13,3 @@
|
||||
@import "shadows.scss";
|
||||
@import "syntax-highlighting.scss";
|
||||
@import "utilities.scss";
|
||||
|
||||
// render a mostly gray background until we know the color mode via XHR
|
||||
html,
|
||||
body {
|
||||
background: #6e7781;
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, test } from '@jest/globals'
|
||||
|
||||
import { getTheme, defaultCSSTheme, defaultComponentTheme } from '../../lib/get-theme.js'
|
||||
|
||||
function serializeCookieValue(obj) {
|
||||
return encodeURIComponent(JSON.stringify(obj))
|
||||
}
|
||||
|
||||
describe('getTheme basics', () => {
|
||||
test('always return an object with certain keys', () => {
|
||||
const req = {} // doesn't even have a `.cookies`.
|
||||
const theme = getTheme(req)
|
||||
expect(theme.colorMode).toBe(defaultComponentTheme.colorMode)
|
||||
expect(theme.nightTheme).toBe(defaultComponentTheme.nightTheme)
|
||||
expect(theme.dayTheme).toBe(defaultComponentTheme.dayTheme)
|
||||
const cssTheme = getTheme(req, true)
|
||||
expect(cssTheme.colorMode).toBe(defaultCSSTheme.colorMode)
|
||||
expect(cssTheme.nightTheme).toBe(defaultCSSTheme.nightTheme)
|
||||
expect(cssTheme.dayTheme).toBe(defaultCSSTheme.dayTheme)
|
||||
})
|
||||
|
||||
test('respect the color_mode cookie value', () => {
|
||||
const req = {
|
||||
cookies: {
|
||||
color_mode: serializeCookieValue({
|
||||
color_mode: 'dark',
|
||||
light_theme: { name: 'light_colorblind', color_mode: 'light' },
|
||||
dark_theme: { name: 'dark_tritanopia', color_mode: 'dark' },
|
||||
}),
|
||||
},
|
||||
}
|
||||
const theme = getTheme(req)
|
||||
expect(theme.colorMode).toBe('night')
|
||||
expect(theme.nightTheme).toBe(defaultComponentTheme.nightTheme)
|
||||
expect(theme.dayTheme).toBe(defaultComponentTheme.dayTheme)
|
||||
|
||||
const cssTheme = getTheme(req, true)
|
||||
expect(cssTheme.colorMode).toBe('dark')
|
||||
expect(cssTheme.nightTheme).toBe(defaultCSSTheme.nightTheme)
|
||||
expect(cssTheme.dayTheme).toBe(defaultCSSTheme.dayTheme)
|
||||
})
|
||||
|
||||
test('respect the color_mode cookie value', () => {
|
||||
const req = {
|
||||
cookies: {
|
||||
color_mode: serializeCookieValue({
|
||||
color_mode: 'dark',
|
||||
light_theme: { name: 'light_colorblind', color_mode: 'light' },
|
||||
dark_theme: { name: 'dark_tritanopia', color_mode: 'dark' },
|
||||
}),
|
||||
},
|
||||
}
|
||||
const theme = getTheme(req)
|
||||
expect(theme.colorMode).toBe('night')
|
||||
expect(theme.nightTheme).toBe(defaultComponentTheme.nightTheme)
|
||||
expect(theme.dayTheme).toBe(defaultComponentTheme.dayTheme)
|
||||
|
||||
const cssTheme = getTheme(req, true)
|
||||
expect(cssTheme.colorMode).toBe('dark')
|
||||
expect(cssTheme.nightTheme).toBe(defaultCSSTheme.nightTheme)
|
||||
expect(cssTheme.dayTheme).toBe(defaultCSSTheme.dayTheme)
|
||||
})
|
||||
|
||||
test('ignore "junk" cookie values', () => {
|
||||
const req = {
|
||||
cookies: {
|
||||
color_mode: '[This is not valid JSON}',
|
||||
},
|
||||
}
|
||||
const theme = getTheme(req)
|
||||
expect(theme.colorMode).toBe('auto')
|
||||
expect(theme.nightTheme).toBe(defaultComponentTheme.nightTheme)
|
||||
expect(theme.dayTheme).toBe(defaultComponentTheme.dayTheme)
|
||||
|
||||
const cssTheme = getTheme(req, true)
|
||||
expect(cssTheme.colorMode).toBe('auto')
|
||||
expect(cssTheme.nightTheme).toBe(defaultCSSTheme.nightTheme)
|
||||
expect(cssTheme.dayTheme).toBe(defaultCSSTheme.dayTheme)
|
||||
})
|
||||
})
|
||||
39
tests/unit/use-theme.js
Normal file
39
tests/unit/use-theme.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from '@jest/globals'
|
||||
import {
|
||||
getComponentTheme,
|
||||
getCssTheme,
|
||||
defaultCSSTheme,
|
||||
defaultComponentTheme,
|
||||
} from '../../components/hooks/useTheme.ts'
|
||||
|
||||
describe('getTheme basics', () => {
|
||||
test('always return an object with certain keys', () => {
|
||||
const cookieValue = JSON.stringify({})
|
||||
expect(getCssTheme(cookieValue)).toEqual(defaultCSSTheme)
|
||||
expect(getComponentTheme(cookieValue)).toEqual(defaultComponentTheme)
|
||||
})
|
||||
|
||||
test('ignore "junk" cookie values', () => {
|
||||
const cookieValue = '[This is not valid JSON}'
|
||||
expect(getCssTheme(cookieValue)).toEqual(defaultCSSTheme)
|
||||
expect(getComponentTheme(cookieValue)).toEqual(defaultComponentTheme)
|
||||
})
|
||||
|
||||
test('respect the color_mode cookie value', () => {
|
||||
const cookieValue = JSON.stringify({
|
||||
color_mode: 'dark',
|
||||
light_theme: { name: 'light_colorblind', color_mode: 'light' },
|
||||
dark_theme: { name: 'dark_tritanopia', color_mode: 'dark' },
|
||||
})
|
||||
|
||||
const cssTheme = getCssTheme(cookieValue)
|
||||
expect(cssTheme.colorMode).toBe('dark')
|
||||
expect(cssTheme.darkTheme).toBe(defaultCSSTheme.darkTheme)
|
||||
expect(cssTheme.lightTheme).toBe(defaultCSSTheme.lightTheme)
|
||||
|
||||
const componentTheme = getComponentTheme(cookieValue)
|
||||
expect(componentTheme.colorMode).toBe('night')
|
||||
expect(componentTheme.nightScheme).toBe(defaultComponentTheme.nightScheme)
|
||||
expect(componentTheme.dayScheme).toBe(defaultComponentTheme.dayScheme)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user