diff --git a/.prettierignore b/.prettierignore index 7d78f4af5a1..16c0deedbe0 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,3 +9,4 @@ docs/.cache docs/public **/.next/** packages/react/src/legacy-theme/ts/color-schemes.ts +packages/styled-react/src/theming/legacy-theme/ts/colors/color-schemes.ts diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index d34f9dce59c..57bc4799a98 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -207,6 +207,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "useFocusTrap", "useFocusZone", "useFormControlForwardedProps", + "useId", "useIsomorphicLayoutEffect", "useOnEscapePress", "useOnOutsideClick", @@ -217,6 +218,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "useResizeObserver", "useResponsiveValue", "useSafeTimeout", + "useSyncedState", "useTheme", "VisuallyHidden", "type VisuallyHiddenProps", diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 9578768eee8..c56e968a6df 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -46,6 +46,8 @@ export {useResizeObserver} from './hooks/useResizeObserver' export {useResponsiveValue, type ResponsiveValue} from './hooks/useResponsiveValue' export {default as useIsomorphicLayoutEffect} from './utils/useIsomorphicLayoutEffect' export {useProvidedRefOrCreate} from './hooks/useProvidedRefOrCreate' +export {useId} from './hooks/useId' +export {useSyncedState} from './hooks/useSyncedState' // Utils export {createComponent} from './utils/create-component' diff --git a/packages/styled-react/src/__tests__/ThemeProvider.browser.test.tsx b/packages/styled-react/src/__tests__/ThemeProvider.browser.test.tsx new file mode 100644 index 00000000000..0ac937b6880 --- /dev/null +++ b/packages/styled-react/src/__tests__/ThemeProvider.browser.test.tsx @@ -0,0 +1,646 @@ +import {render, screen, waitFor} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import {describe, expect, it, vi} from 'vitest' +import React from 'react' +import {Text, ThemeProvider, useColorSchemeVar, useTheme} from '..' + +// window.matchMedia() is not implemented by JSDOM so we have to create a mock: +// https://vijs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), // deprecated + removeListener: vi.fn(), // deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}) + +const exampleTheme = { + colors: { + text: '#f00', + }, + colorSchemes: { + light: { + colors: { + text: 'black', + }, + }, + dark: { + colors: { + text: 'white', + }, + }, + dark_dimmed: { + colors: { + text: 'gray', + }, + }, + }, +} + +it('respects theme prop', () => { + const theme = { + colors: { + text: '#f00', + }, + space: ['0', '0.25rem'], + } + + render( + + + Hello + + , + ) + + expect(screen.getByText('Hello')).toHaveStyle('color: #f00') + expect(screen.getByText('Hello')).toHaveStyle('margin-bottom: 4px') +}) + +it('has default theme', () => { + render( + + + Hello + + , + ) + + expect(screen.getByText('Hello')).toMatchSnapshot() +}) + +it('inherits theme from parent', () => { + render( + + + Hello + + , + ) + + expect(screen.getByText('Hello')).toHaveStyle('color: rgb(0, 0, 0)') +}) + +it('defaults to light color scheme', () => { + render( + + Hello + , + ) + + expect(screen.getByText('Hello')).toHaveStyle('color: rgb(0, 0, 0)') +}) + +it('defaults to dark color scheme in night mode', () => { + render( + + Hello + , + ) + + expect(screen.getByText('Hello')).toHaveStyle('color: rgb(255, 255, 255)') +}) + +it('defaults to first color scheme when passed an invalid color scheme name', () => { + const spy = vi.spyOn(console, 'error').mockImplementationOnce(() => {}) + + render( + + Hello + , + ) + + expect(spy).toHaveBeenCalledWith('`foo` scheme not defined in `theme.colorSchemes`') + expect(screen.getByText('Hello')).toHaveStyle('color: rgb(0, 0, 0)') + + spy.mockRestore() +}) + +it('respects nightScheme prop', () => { + render( + + Hello + , + ) + + expect(screen.getByText('Hello')).toHaveStyle('color: rgb(128, 128, 128)') +}) + +it('respects nightScheme prop with colorMode="dark"', () => { + render( + + Hello + , + ) + + expect(screen.getByText('Hello')).toHaveStyle('color: rgb(128, 128, 128)') +}) + +it('respects dayScheme prop', () => { + render( + + Hello + , + ) + + expect(screen.getByText('Hello')).toHaveStyle('color: rgb(255, 255, 255)') +}) + +it('respects dayScheme prop with colorMode="light"', () => { + render( + + Hello + , + ) + + expect(screen.getByText('Hello')).toHaveStyle('color: rgb(255, 255, 255)') +}) + +it('works in auto mode', () => { + render( + + Hello + , + ) + + expect(screen.getByText('Hello')).toHaveStyle('color: rgb(0, 0, 0)') +}) + +it('works in auto mode (dark)', () => { + const matchMediaSpy = vi.spyOn(window, 'matchMedia').mockImplementation(query => ({ + matches: true, // enable dark mode + media: query, + onchange: null, + addListener: vi.fn(), // deprecated + removeListener: vi.fn(), // deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + + render( + + Hello + , + ) + + expect(screen.getByText('Hello')).toHaveStyle('color: rgb(255, 255, 255)') + + matchMediaSpy.mockRestore() +}) + +it('updates when colorMode prop changes', async () => { + const user = userEvent.setup() + + function App() { + const [colorMode, setColorMode] = React.useState<'day' | 'night'>('day') + return ( + + {colorMode} + + + ) + } + + render() + + // starts in day mode (light scheme) + expect(screen.getByText('day')).toHaveStyle('color: rgb(0, 0, 0)') + + await user.click(screen.getByRole('button')) + + await waitFor(() => + // clicking the toggle button enables night mode (dark scheme) + expect(screen.getByText('night')).toHaveStyle('color: rgb(255, 255, 255)'), + ) +}) + +it('updates when dayScheme prop changes', async () => { + const user = userEvent.setup() + + function App() { + const [dayScheme, setDayScheme] = React.useState('light') + return ( + + {dayScheme} + + + ) + } + + render() + + // starts in day mode (light scheme) + expect(screen.getByText('light')).toHaveStyle('color: rgb(0, 0, 0)') + + await user.click(screen.getByRole('button')) + + await waitFor(() => + // clicking the toggle sets the day scheme to dark_dimmed + expect(screen.getByText('dark_dimmed')).toHaveStyle('color: rgb(128, 128, 128)'), + ) +}) + +it('updates when nightScheme prop changes', async () => { + const user = userEvent.setup() + + function App() { + const [nightScheme, setNightScheme] = React.useState('dark') + return ( + + {nightScheme} + + + ) + } + + render() + + // starts in night mode (dark scheme) + expect(screen.getByText('dark')).toHaveStyle('color: rgb(255, 255, 255)') + + await user.click(screen.getByRole('button')) + + await waitFor(() => + // clicking the toggle button sets the night scheme to dark_dimmed + expect(screen.getByText('dark_dimmed')).toHaveStyle('color: rgb(128, 128, 128)'), + ) +}) + +it('inherits colorMode from parent', async () => { + const user = userEvent.setup() + + function App() { + const [colorMode, setcolorMode] = React.useState<'day' | 'night'>('day') + return ( + + + + {colorMode} + + + ) + } + + render() + + expect(screen.getByText('day')).toHaveStyle('color: rgb(0, 0, 0)') + + await user.click(screen.getByRole('button')) + + await waitFor(() => expect(screen.getByText('night')).toHaveStyle('color: rgb(255, 255, 255)')) +}) + +it('inherits dayScheme from parent', async () => { + const user = userEvent.setup() + + function App() { + const [dayScheme, setDayScheme] = React.useState('light') + return ( + + + + {dayScheme} + + + ) + } + + render() + + expect(screen.getByText('light')).toHaveStyle('color: rgb(0, 0, 0)') + + await user.click(screen.getByRole('button')) + + await waitFor(() => expect(screen.getByText('dark_dimmed')).toHaveStyle('color: rgb(128, 128, 128)')) +}) + +it('inherits nightScheme from parent', async () => { + const user = userEvent.setup() + + function App() { + const [nightScheme, setNightScheme] = React.useState('dark') + return ( + + + + {nightScheme} + + + ) + } + + render() + + expect(screen.getByText('dark')).toHaveStyle('color: rgb(255, 255, 255)') + + await user.click(screen.getByRole('button')) + + await waitFor(() => expect(screen.getByText('dark_dimmed')).toHaveStyle('color: rgb(128, 128, 128)')) +}) + +describe('setColorMode', () => { + it('changes the color mode', async () => { + const user = userEvent.setup() + + function ToggleMode() { + const {colorMode, setColorMode} = useTheme() + return ( + + ) + } + + render( + + Hello + + , + ) + + // starts in day mode (light scheme) + expect(screen.getByText('Hello')).toHaveStyle('color: rgb(0, 0, 0)') + + await user.click(screen.getByRole('button')) + + // clicking the toggle button enables night mode (dark scheme) + expect(screen.getByText('Hello')).toHaveStyle('color: rgb(255, 255, 255)') + }) +}) + +describe('setDayScheme', () => { + it('changes the day scheme', async () => { + const user = userEvent.setup() + + function ToggleDayScheme() { + const {dayScheme, setDayScheme} = useTheme() + return ( + + ) + } + + render( + + Hello + + , + ) + + // starts in day mode (light scheme) + expect(screen.getByText('Hello')).toHaveStyle('color: rgb(0, 0, 0)') + + await user.click(screen.getByRole('button')) + + // clicking the toggle button sets day scheme to dark + expect(screen.getByText('Hello')).toHaveStyle('color: rgb(255, 255, 255)') + }) +}) + +describe('setNightScheme', () => { + it('changes the night scheme', async () => { + const user = userEvent.setup() + + function ToggleNightScheme() { + const {nightScheme, setNightScheme} = useTheme() + return ( + + ) + } + + render( + + Hello + + , + ) + + // starts in night mode (dark scheme) + expect(screen.getByText('Hello')).toHaveStyle('color: rgb(255, 255, 255)') + + await user.click(screen.getByRole('button')) + + // clicking the toggle button sets night scheme to dark_dimmed + expect(screen.getByText('Hello')).toHaveStyle('color: rgb(128, 128, 128)') + }) +}) + +describe('useColorSchemeVar', () => { + it('updates value when scheme changes', async () => { + const user = userEvent.setup() + + function ToggleMode() { + const {colorMode, setColorMode} = useTheme() + return ( + + ) + } + + function CustomBg() { + const customBg = useColorSchemeVar( + { + light: 'red', + dark: 'blue', + dark_dimmed: 'green', + }, + 'inherit', + ) + + return Hello + } + + render( + + + + , + ) + + expect(screen.getByText('Hello')).toHaveStyle('background-color: rgb(255, 0, 0)') + + await user.click(screen.getByRole('button')) + + expect(screen.getByText('Hello')).toHaveStyle('background-color: rgb(0, 128, 0)') + }) + + it('supports fallback value', async () => { + const user = userEvent.setup() + + function ToggleMode() { + const {colorMode, setColorMode} = useTheme() + return ( + + ) + } + + function CustomBg() { + const customBg = useColorSchemeVar({dark: 'blue'}, 'red') + + return Hello + } + + render( + + + + , + ) + + expect(screen.getByText('Hello')).toHaveStyle('background-color: rgb(255, 0, 0)') + + await user.click(screen.getByRole('button')) + + expect(screen.getByText('Hello')).toHaveStyle('background-color: rgb(0, 0, 255)') + }) +}) + +describe('useTheme().resolvedColorScheme', () => { + it('is undefined when not in a theme', () => { + const Component = () => { + const {resolvedColorScheme} = useTheme() + + return {resolvedColorScheme} + } + + render() + + expect(screen.getByTestId('text').textContent).toEqual('') + }) + + it('is undefined when the theme has no colorScheme object', () => { + const Component = () => { + const {resolvedColorScheme} = useTheme() + + return {resolvedColorScheme} + } + + render( + + + , + ) + + expect(screen.getByTestId('text').textContent).toEqual('') + }) + + it('is the same as the applied colorScheme, when that colorScheme is in the theme', () => { + const Component = () => { + const {resolvedColorScheme} = useTheme() + + return {resolvedColorScheme} + } + + const schemeToApply = 'dark' + + render( + + + , + ) + + expect(exampleTheme.colorSchemes).toHaveProperty(schemeToApply) + expect(screen.getByTestId('text').textContent).toEqual(schemeToApply) + }) + + it('is the value of the fallback colorScheme applied when attempting to apply an invalid colorScheme', () => { + const spy = vi.spyOn(console, 'error').mockImplementationOnce(() => {}) + const Component = () => { + const {resolvedColorScheme} = useTheme() + + return {resolvedColorScheme} + } + + const schemeToApply = 'totally-invalid-colorscheme' + render( + + + , + ) + + const defaultThemeColorScheme = Object.keys(exampleTheme.colorSchemes)[0] + + expect(spy).toHaveBeenCalledWith('`totally-invalid-colorscheme` scheme not defined in `theme.colorSchemes`') + expect(defaultThemeColorScheme).not.toEqual(schemeToApply) + expect(exampleTheme.colorSchemes).not.toHaveProperty(schemeToApply) + expect(screen.getByTestId('text').textContent).toEqual('light') + + spy.mockRestore() + }) + + describe('nested theme', () => { + it('is the same as the applied colorScheme, when that colorScheme is in the theme', () => { + const Component = () => { + const {resolvedColorScheme} = useTheme() + + return {resolvedColorScheme} + } + + const schemeToApply = 'dark' + + render( + + + + + , + ) + + expect(exampleTheme.colorSchemes).toHaveProperty(schemeToApply) + expect(screen.getByTestId('text').textContent).toEqual(schemeToApply) + }) + + it('is the value of the fallback colorScheme applied when attempting to apply an invalid colorScheme', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const Component = () => { + const {resolvedColorScheme} = useTheme() + + return {resolvedColorScheme} + } + + const schemeToApply = 'totally-invalid-colorscheme' + render( + + + + + , + ) + + const defaultThemeColorScheme = Object.keys(exampleTheme.colorSchemes)[0] + + expect(spy).toHaveBeenCalledWith('`totally-invalid-colorscheme` scheme not defined in `theme.colorSchemes`') + expect(defaultThemeColorScheme).not.toEqual(schemeToApply) + expect(exampleTheme.colorSchemes).not.toHaveProperty(schemeToApply) + expect(screen.getByTestId('text').textContent).toEqual('light') + + spy.mockRestore() + }) + }) +}) diff --git a/packages/styled-react/src/index.tsx b/packages/styled-react/src/index.tsx index 6bb625118f4..b44d99ad512 100644 --- a/packages/styled-react/src/index.tsx +++ b/packages/styled-react/src/index.tsx @@ -146,13 +146,13 @@ export { // styled-components components or types Box, sx, - - // theming depends on styled-components - ThemeProvider, - merge, - theme, - themeGet, - useColorSchemeVar, - useTheme, } from '@primer/react' + export type {BoxProps, SxProp, BetterSystemStyleObject} + +// theming depends on styled-components and styled-system +export {default as theme} from './theming/theme' +export {default as ThemeProvider, useTheme, useColorSchemeVar} from './theming/ThemeProvider' +export type {ThemeProviderProps} from './theming/ThemeProvider' +export {get as themeGet} from './theming/themeGet' +export {default as merge} from 'deepmerge' diff --git a/packages/styled-react/src/theming/README.md b/packages/styled-react/src/theming/README.md new file mode 100644 index 00000000000..195d53865b1 --- /dev/null +++ b/packages/styled-react/src/theming/README.md @@ -0,0 +1,3 @@ +# Theming + +Files in this folder have been copied from the theming setup in `@primer/react` diff --git a/packages/styled-react/src/theming/ThemeProvider.tsx b/packages/styled-react/src/theming/ThemeProvider.tsx new file mode 100644 index 00000000000..812f3426c56 --- /dev/null +++ b/packages/styled-react/src/theming/ThemeProvider.tsx @@ -0,0 +1,244 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import {ThemeProvider as SCThemeProvider} from 'styled-components' +import deepmerge from 'deepmerge' +import {useId, useSyncedState} from '@primer/react' +import defaultTheme from './theme' + +export const defaultColorMode = 'day' +const defaultDayScheme = 'light' +const defaultNightScheme = 'dark' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Theme = {[key: string]: any} +type ColorMode = 'day' | 'night' | 'light' | 'dark' +export type ColorModeWithAuto = ColorMode | 'auto' + +export type ThemeProviderProps = { + theme?: Theme + colorMode?: ColorModeWithAuto + dayScheme?: string + nightScheme?: string + preventSSRMismatch?: boolean +} + +const ThemeContext = React.createContext<{ + theme?: Theme + colorScheme?: string + colorMode?: ColorModeWithAuto + resolvedColorMode?: ColorMode + resolvedColorScheme?: string + dayScheme?: string + nightScheme?: string + setColorMode: React.Dispatch> + setDayScheme: React.Dispatch> + setNightScheme: React.Dispatch> +}>({ + setColorMode: () => null, + setDayScheme: () => null, + setNightScheme: () => null, +}) + +// inspired from __NEXT_DATA__, we use application/json to avoid CSRF policy with inline scripts +const getServerHandoff = (id: string) => { + try { + const serverData = document.getElementById(`__PRIMER_DATA_${id}__`)?.textContent + if (serverData) return JSON.parse(serverData) + } catch (_error) { + // if document/element does not exist or JSON is invalid, supress error + } + return {} +} + +export const ThemeProvider: React.FC> = ({children, ...props}) => { + // Get fallback values from parent ThemeProvider (if exists) + const { + theme: fallbackTheme, + colorMode: fallbackColorMode, + dayScheme: fallbackDayScheme, + nightScheme: fallbackNightScheme, + } = useTheme() + + // Initialize state + const theme = props.theme ?? fallbackTheme ?? defaultTheme + + const uniqueDataId = useId() + const {resolvedServerColorMode} = getServerHandoff(uniqueDataId) + const resolvedColorModePassthrough = React.useRef(resolvedServerColorMode) + + const [colorMode, setColorMode] = useSyncedState(props.colorMode ?? fallbackColorMode ?? defaultColorMode) + const [dayScheme, setDayScheme] = useSyncedState(props.dayScheme ?? fallbackDayScheme ?? defaultDayScheme) + const [nightScheme, setNightScheme] = useSyncedState(props.nightScheme ?? fallbackNightScheme ?? defaultNightScheme) + const systemColorMode = useSystemColorMode() + const resolvedColorMode = resolvedColorModePassthrough.current || resolveColorMode(colorMode, systemColorMode) + const colorScheme = chooseColorScheme(resolvedColorMode, dayScheme, nightScheme) + const {resolvedTheme, resolvedColorScheme} = React.useMemo( + () => applyColorScheme(theme, colorScheme), + [theme, colorScheme], + ) + + // this effect will only run on client + React.useEffect( + function updateColorModeAfterServerPassthrough() { + const resolvedColorModeOnClient = resolveColorMode(colorMode, systemColorMode) + + if (resolvedColorModePassthrough.current) { + // if the resolved color mode passed on from the server is not the resolved color mode on client, change it! + if (resolvedColorModePassthrough.current !== resolvedColorModeOnClient) { + window.setTimeout(() => { + // use ReactDOM.flushSync to prevent automatic batching of state updates since React 18 + // ref: https://github.com/reactwg/react-18/discussions/21 + ReactDOM.flushSync(() => { + // override colorMode to whatever is resolved on the client to get a re-render + setColorMode(resolvedColorModeOnClient) + }) + + // immediately after that, set the colorMode to what the user passed to respond to system color mode changes + setColorMode(colorMode) + }) + } + + resolvedColorModePassthrough.current = null + } + }, + [colorMode, systemColorMode, setColorMode], + ) + + return ( + + + {children} + {props.preventSSRMismatch ? ( +